├── logo.png ├── .pdd ├── .gitignore ├── features ├── support │ └── env.rb ├── gem_package.feature ├── step_definitions │ └── steps.rb └── cli.feature ├── lib ├── jekyll-chatgpt-translate.rb └── jekyll-chatgpt-translate │ ├── version.rb │ ├── pars.rb │ ├── permalink.rb │ ├── ping.rb │ ├── prompt.rb │ ├── plain.rb │ ├── chatgpt.rb │ └── generator.rb ├── .0pdd.yml ├── renovate.json ├── .github └── workflows │ ├── reuse.yml │ ├── typos.yml │ ├── xcop.yml │ ├── pdd.yml │ ├── yamllint.yml │ ├── copyrights.yml │ ├── markdown-lint.yml │ ├── actionlint.yml │ └── rake.yml ├── Gemfile ├── REUSE.toml ├── .rultor.yml ├── test ├── test_permalink.rb ├── test_prompt.rb ├── test_ping.rb ├── test_pars.rb ├── test_generator.rb ├── test__helper.rb ├── test_chatgpt.rb └── test_plain.rb ├── LICENSE.txt ├── LICENSES └── MIT.txt ├── .rubocop.yml ├── Rakefile ├── jekyll-chatgpt-translate.gemspec └── README.md /logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yegor256/jekyll-chatgpt-translate/HEAD/logo.png -------------------------------------------------------------------------------- /.pdd: -------------------------------------------------------------------------------- 1 | --source=. 2 | --verbose 3 | --exclude target/**/* 4 | --exclude src/main/resources/images/**/* 5 | --rule min-words:20 6 | --rule min-estimate:15 7 | --rule max-estimate:90 8 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | _chatgpt-translate/ 2 | .bundle/ 3 | .DS_Store 4 | .idea/ 5 | .yardoc/ 6 | *.gem 7 | coverage/ 8 | doc/ 9 | Gemfile.lock 10 | node_modules/ 11 | rdoc/ 12 | vendor/ 13 | -------------------------------------------------------------------------------- /features/support/env.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # SPDX-FileCopyrightText: Copyright (c) 2023-2025 Yegor Bugayenko 4 | # SPDX-License-Identifier: MIT 5 | 6 | require 'simplecov' 7 | -------------------------------------------------------------------------------- /lib/jekyll-chatgpt-translate.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # SPDX-FileCopyrightText: Copyright (c) 2023-2025 Yegor Bugayenko 4 | # SPDX-License-Identifier: MIT 5 | 6 | require 'jekyll-chatgpt-translate/generator' 7 | -------------------------------------------------------------------------------- /lib/jekyll-chatgpt-translate/version.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # SPDX-FileCopyrightText: Copyright (c) 2023-2025 Yegor Bugayenko 4 | # SPDX-License-Identifier: MIT 5 | 6 | module GptTranslate 7 | VERSION = '0.0.0' 8 | end 9 | -------------------------------------------------------------------------------- /.0pdd.yml: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: Copyright (c) 2023-2025 Yegor Bugayenko 2 | # SPDX-License-Identifier: MIT 3 | --- 4 | errors: 5 | - yegor256@gmail.com 6 | # alerts: 7 | # github: 8 | # - yegor256 9 | 10 | tags: 11 | - pdd 12 | - bug 13 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "extends": [ 4 | "config:base" 5 | ], 6 | "ignorePaths": [ 7 | "jekyll-tests/jekyll-3/Gemfile", 8 | "jekyll-tests/jekyll-4/Gemfile" 9 | ] 10 | } 11 | -------------------------------------------------------------------------------- /.github/workflows/reuse.yml: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: Copyright (c) 2023-2025 Yegor Bugayenko 2 | # SPDX-License-Identifier: MIT 3 | --- 4 | # yamllint disable rule:line-length 5 | name: reuse 6 | 'on': 7 | push: 8 | branches: 9 | - master 10 | pull_request: 11 | branches: 12 | - master 13 | jobs: 14 | reuse: 15 | timeout-minutes: 15 16 | runs-on: ubuntu-24.04 17 | steps: 18 | - uses: actions/checkout@v4 19 | - uses: fsfe/reuse-action@v5 20 | -------------------------------------------------------------------------------- /.github/workflows/typos.yml: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: Copyright (c) 2023-2025 Yegor Bugayenko 2 | # SPDX-License-Identifier: MIT 3 | --- 4 | # yamllint disable rule:line-length 5 | name: typos 6 | 'on': 7 | push: 8 | branches: 9 | - master 10 | pull_request: 11 | branches: 12 | - master 13 | jobs: 14 | typos: 15 | timeout-minutes: 15 16 | runs-on: ubuntu-24.04 17 | steps: 18 | - uses: actions/checkout@v4 19 | - uses: crate-ci/typos@v1.32.0 20 | -------------------------------------------------------------------------------- /.github/workflows/xcop.yml: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: Copyright (c) 2023-2025 Yegor Bugayenko 2 | # SPDX-License-Identifier: MIT 3 | --- 4 | # yamllint disable rule:line-length 5 | name: xcop 6 | 'on': 7 | push: 8 | branches: 9 | - master 10 | pull_request: 11 | branches: 12 | - master 13 | jobs: 14 | xcop: 15 | timeout-minutes: 15 16 | runs-on: ubuntu-24.04 17 | steps: 18 | - uses: actions/checkout@v4 19 | - uses: g4s8/xcop-action@master 20 | -------------------------------------------------------------------------------- /.github/workflows/pdd.yml: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: Copyright (c) 2023-2025 Yegor Bugayenko 2 | # SPDX-License-Identifier: MIT 3 | --- 4 | # yamllint disable rule:line-length 5 | name: pdd 6 | 'on': 7 | push: 8 | branches: 9 | - master 10 | pull_request: 11 | branches: 12 | - master 13 | jobs: 14 | pdd: 15 | timeout-minutes: 15 16 | runs-on: ubuntu-24.04 17 | steps: 18 | - uses: actions/checkout@v4 19 | - uses: volodya-lombrozo/pdd-action@master 20 | -------------------------------------------------------------------------------- /.github/workflows/yamllint.yml: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: Copyright (c) 2023-2025 Yegor Bugayenko 2 | # SPDX-License-Identifier: MIT 3 | --- 4 | # yamllint disable rule:line-length 5 | name: yamllint 6 | 'on': 7 | push: 8 | branches: 9 | - master 10 | pull_request: 11 | branches: 12 | - master 13 | jobs: 14 | yamllint: 15 | timeout-minutes: 15 16 | runs-on: ubuntu-24.04 17 | steps: 18 | - uses: actions/checkout@v4 19 | - uses: ibiqlik/action-yamllint@v3 20 | -------------------------------------------------------------------------------- /.github/workflows/copyrights.yml: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: Copyright (c) 2023-2025 Yegor Bugayenko 2 | # SPDX-License-Identifier: MIT 3 | --- 4 | # yamllint disable rule:line-length 5 | name: copyrights 6 | 'on': 7 | push: 8 | branches: 9 | - master 10 | pull_request: 11 | branches: 12 | - master 13 | jobs: 14 | copyrights: 15 | timeout-minutes: 15 16 | runs-on: ubuntu-24.04 17 | steps: 18 | - uses: actions/checkout@v4 19 | - uses: yegor256/copyrights-action@0.0.8 20 | -------------------------------------------------------------------------------- /.github/workflows/markdown-lint.yml: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: Copyright (c) 2023-2025 Yegor Bugayenko 2 | # SPDX-License-Identifier: MIT 3 | --- 4 | # yamllint disable rule:line-length 5 | name: markdown-lint 6 | 'on': 7 | push: 8 | branches: 9 | - master 10 | pull_request: 11 | branches: 12 | - master 13 | paths-ignore: ['paper/**', 'sandbox/**'] 14 | concurrency: 15 | group: markdown-lint-${{ github.ref }} 16 | cancel-in-progress: true 17 | jobs: 18 | markdown-lint: 19 | timeout-minutes: 15 20 | runs-on: ubuntu-24.04 21 | steps: 22 | - uses: actions/checkout@v4 23 | - uses: DavidAnson/markdownlint-cli2-action@v20.0.0 24 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # SPDX-FileCopyrightText: Copyright (c) 2023-2025 Yegor Bugayenko 4 | # SPDX-License-Identifier: MIT 5 | 6 | source 'https://rubygems.org' 7 | gemspec 8 | 9 | gem 'cucumber', '~>9.2', require: false 10 | gem 'kramdown-parser-gfm', '~>1.1', require: false 11 | gem 'minitest', '~>5.25', require: false 12 | gem 'minitest-reporters', '~>1.7', require: false 13 | gem 'rake', '~>13.2', require: false 14 | gem 'rubocop', '~>1.64', require: false 15 | gem 'rubocop-minitest', '>0', require: false 16 | gem 'rubocop-performance', '>0', require: false 17 | gem 'rubocop-rake', '>0', require: false 18 | gem 'simplecov', '~>0.22', require: false 19 | gem 'simplecov-cobertura', '~>3.1', require: false 20 | gem 'webmock', '~>3.24', require: false 21 | -------------------------------------------------------------------------------- /.github/workflows/actionlint.yml: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: Copyright (c) 2023-2025 Yegor Bugayenko 2 | # SPDX-License-Identifier: MIT 3 | --- 4 | # yamllint disable rule:line-length 5 | name: actionlint 6 | 'on': 7 | push: 8 | branches: 9 | - master 10 | pull_request: 11 | branches: 12 | - master 13 | jobs: 14 | actionlint: 15 | timeout-minutes: 15 16 | runs-on: ubuntu-24.04 17 | steps: 18 | - uses: actions/checkout@v4 19 | - name: Download actionlint 20 | id: get_actionlint 21 | run: bash <(curl https://raw.githubusercontent.com/rhysd/actionlint/main/scripts/download-actionlint.bash) 22 | shell: bash 23 | - name: Check workflow files 24 | run: ${{ steps.get_actionlint.outputs.executable }} -color 25 | shell: bash 26 | -------------------------------------------------------------------------------- /.github/workflows/rake.yml: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: Copyright (c) 2023-2025 Yegor Bugayenko 2 | # SPDX-License-Identifier: MIT 3 | --- 4 | # yamllint disable rule:line-length 5 | name: rake 6 | 'on': 7 | push: 8 | branches: 9 | - master 10 | pull_request: 11 | branches: 12 | - master 13 | jobs: 14 | test: 15 | strategy: 16 | matrix: 17 | os: [ubuntu-24.04, macos-15, windows-2022] 18 | ruby: [3.3] 19 | runs-on: ${{ matrix.os }} 20 | steps: 21 | - uses: actions/checkout@v4 22 | - uses: ruby/setup-ruby@v1 23 | with: 24 | ruby-version: ${{ matrix.ruby }} 25 | bundler-cache: true 26 | - run: bundle config set --global path "$(pwd)/vendor/bundle" 27 | - run: bundle install --no-color 28 | - run: bundle exec rake 29 | -------------------------------------------------------------------------------- /REUSE.toml: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: Copyright (c) 2025 Yegor Bugayenko 2 | # SPDX-License-Identifier: MIT 3 | 4 | version = 1 5 | [[annotations]] 6 | path = [ 7 | ".DS_Store", 8 | ".gitattributes", 9 | ".gitignore", 10 | ".pdd", 11 | "**.json", 12 | "**.md", 13 | "**.png", 14 | "**.txt", 15 | "**/.DS_Store", 16 | "**/.gitignore", 17 | "**/.pdd", 18 | "**/*.csv", 19 | "**/*.jpg", 20 | "**/*.json", 21 | "**/*.md", 22 | "**/*.pdf", 23 | "**/*.png", 24 | "**/*.svg", 25 | "**/*.txt", 26 | "**/*.vm", 27 | "**/CNAME", 28 | "**/Gemfile.lock", 29 | "Gemfile.lock", 30 | "README.md", 31 | "renovate.json", 32 | ] 33 | precedence = "override" 34 | SPDX-FileCopyrightText = "Copyright (c) 2025 Yegor Bugayenko" 35 | SPDX-License-Identifier = "MIT" 36 | -------------------------------------------------------------------------------- /features/gem_package.feature: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: Copyright (c) 2023-2025 Yegor Bugayenko 2 | # SPDX-License-Identifier: MIT 3 | Feature: Gem Package 4 | As a source code writer I want to be able to 5 | package the Gem into .gem file 6 | 7 | Scenario: Gem can be packaged 8 | When It is Unix 9 | Given I have a "execs.rb" file with content: 10 | """ 11 | #!/usr/bin/env ruby 12 | require 'rubygems' 13 | spec = Gem::Specification::load('./spec.rb') 14 | """ 15 | And I copy this gem into temp dir 16 | When I run bash with: 17 | """ 18 | set -x 19 | set -e 20 | cd jekyll-chatgpt-translate 21 | gem build jekyll-chatgpt-translate.gemspec 22 | gem specification --ruby jekyll-chatgpt-translate-*.gem > ../spec.rb 23 | cd .. 24 | ruby execs.rb 25 | """ 26 | Then Exit code is zero 27 | -------------------------------------------------------------------------------- /.rultor.yml: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: Copyright (c) 2023-2025 Yegor Bugayenko 2 | # SPDX-License-Identifier: MIT 3 | --- 4 | # yamllint disable rule:line-length 5 | docker: 6 | image: yegor256/rultor-image:1.24.0 7 | assets: 8 | rubygems.yml: yegor256/home#assets/rubygems.yml 9 | install: | 10 | pdd -f /dev/null 11 | bundle install --no-color 12 | release: 13 | pre: false 14 | script: |- 15 | bundle exec rake 16 | rm -rf *.gem 17 | sed -i "s/0\.0\.0/${tag}/g" jekyll-chatgpt-translate.gemspec 18 | sed -i "s/0\.0\.0/${tag}/g" lib/jekyll-chatgpt-translate/version.rb 19 | git add jekyll-chatgpt-translate.gemspec 20 | git add lib/jekyll-chatgpt-translate/version.rb 21 | git commit -m "version set to ${tag}" 22 | gem build jekyll-chatgpt-translate.gemspec 23 | chmod 0600 ../rubygems.yml 24 | gem push *.gem --config-file ../rubygems.yml 25 | merge: 26 | script: |- 27 | bundle exec rake 28 | -------------------------------------------------------------------------------- /lib/jekyll-chatgpt-translate/pars.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # SPDX-FileCopyrightText: Copyright (c) 2023-2025 Yegor Bugayenko 4 | # SPDX-License-Identifier: MIT 5 | 6 | # The module we are in. 7 | module GptTranslate; end 8 | 9 | # Markdown broken down ito pars. 10 | # Author:: Yegor Bugayenko (yegor256@gmail.com) 11 | # Copyright:: Copyright (c) 2023-2025 Yegor Bugayenko 12 | # License:: MIT 13 | class GptTranslate::Pars 14 | # Ctor. 15 | # +markdown+ The markdown 16 | def initialize(markdown) 17 | @markdown = markdown 18 | end 19 | 20 | # Returns an array of strings 21 | def to_a 22 | pars = [] 23 | inside = false 24 | @markdown.strip.split(/\n{2,}/).compact.each do |par| 25 | if inside 26 | pars[pars.size - 1] = "#{pars[pars.size - 1]}\n\n#{par}" 27 | else 28 | pars << par 29 | end 30 | inside = true if par.start_with?('```') && !inside 31 | inside = false if par.end_with?('```') && inside 32 | end 33 | pars 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /lib/jekyll-chatgpt-translate/permalink.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # SPDX-FileCopyrightText: Copyright (c) 2023-2025 Yegor Bugayenko 4 | # SPDX-License-Identifier: MIT 5 | 6 | require 'cgi' 7 | 8 | # The module we are in. 9 | module GptTranslate; end 10 | 11 | # Permalink. 12 | # Author:: Yegor Bugayenko (yegor256@gmail.com) 13 | # Copyright:: Copyright (c) 2023-2025 Yegor Bugayenko 14 | # License:: MIT 15 | class GptTranslate::Permalink 16 | def initialize(doc, template) 17 | @doc = doc 18 | raise 'permalink must be defined for each target' if template.nil? 19 | @template = template 20 | end 21 | 22 | def to_path 23 | path = @template 24 | .gsub(':year', format('%04d', @doc['date'].year)) 25 | .gsub(':month', format('%02d', @doc['date'].month)) 26 | .gsub(':day', format('%02d', @doc['date'].day)) 27 | .gsub(':title', CGI.escape(@doc['title'])) 28 | .gsub(':slug', CGI.escape(@doc['slug'])) 29 | path = "/#{path}" unless path.start_with?('/') 30 | path 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /test/test_permalink.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # SPDX-FileCopyrightText: Copyright (c) 2023-2025 Yegor Bugayenko 4 | # SPDX-License-Identifier: MIT 5 | 6 | require_relative 'test__helper' 7 | require_relative '../lib/jekyll-chatgpt-translate/permalink' 8 | 9 | # Permalink test. 10 | # Author:: Yegor Bugayenko (yegor256@gmail.com) 11 | # Copyright:: Copyright (c) 2023-2025 Yegor Bugayenko 12 | # License:: MIT 13 | class GptTranslate::PermalinkTest < Minitest::Test 14 | def test_simple_link 15 | assert_equal( 16 | '/2023.html', 17 | GptTranslate::Permalink.new( 18 | { 'date' => Time.parse('2023-01-01'), 'title' => 'Hello', 'slug' => 'hello' }, 19 | ':year.html' 20 | ).to_path 21 | ) 22 | end 23 | 24 | def test_unicode_link 25 | assert_equal( 26 | '/2023-%23%D0%BF%D1%80%D0%B8%D0%B2%D0%B5%D1%82.html', 27 | GptTranslate::Permalink.new( 28 | { 'date' => Time.parse('2023-01-01'), 'title' => '#привет', 'slug' => 'hello' }, 29 | ':year-:title.html' 30 | ).to_path 31 | ) 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | (The MIT License) 2 | 3 | Copyright (c) 2023-2025 Yegor Bugayenko 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the 'Software'), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /LICENSES/MIT.txt: -------------------------------------------------------------------------------- 1 | (The MIT License) 2 | 3 | Copyright (c) 2023-2025 Yegor Bugayenko 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the 'Software'), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: Copyright (c) 2023-2025 Yegor Bugayenko 2 | # SPDX-License-Identifier: MIT 3 | --- 4 | AllCops: 5 | Exclude: 6 | - 'bin/**/*' 7 | - 'assets/**/*' 8 | - 'vendor/**/*' 9 | DisplayCopNames: true 10 | TargetRubyVersion: 2.7 11 | SuggestExtensions: false 12 | NewCops: enable 13 | plugins: 14 | - rubocop-rake 15 | - rubocop-minitest 16 | - rubocop-performance 17 | Gemspec/RequiredRubyVersion: 18 | Enabled: false 19 | Metrics/MethodLength: 20 | Enabled: false 21 | Style/ClassAndModuleChildren: 22 | Enabled: false 23 | Minitest/EmptyLineBeforeAssertionMethods: 24 | Enabled: false 25 | Layout/MultilineMethodCallIndentation: 26 | Enabled: false 27 | Metrics/AbcSize: 28 | Enabled: false 29 | Minitest/MultipleAssertions: 30 | Max: 5 31 | Metrics/BlockLength: 32 | Max: 100 33 | Metrics/CyclomaticComplexity: 34 | Max: 35 35 | Metrics/PerceivedComplexity: 36 | Max: 40 37 | Layout/EmptyLineAfterGuardClause: 38 | Enabled: false 39 | Naming/FileName: 40 | Enabled: false 41 | Layout/IndentationWidth: 42 | Enabled: false 43 | Layout/ElseAlignment: 44 | Enabled: false 45 | Layout/EndAlignment: 46 | Enabled: false 47 | Metrics/ClassLength: 48 | Max: 200 49 | Style/ClassVars: 50 | Enabled: false 51 | require: [] 52 | -------------------------------------------------------------------------------- /test/test_prompt.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # SPDX-FileCopyrightText: Copyright (c) 2023-2025 Yegor Bugayenko 4 | # SPDX-License-Identifier: MIT 5 | 6 | require_relative 'test__helper' 7 | require_relative '../lib/jekyll-chatgpt-translate/prompt' 8 | 9 | # Prompt test. 10 | # Author:: Yegor Bugayenko (yegor256@gmail.com) 11 | # Copyright:: Copyright (c) 2023-2025 Yegor Bugayenko 12 | # License:: MIT 13 | class GptTranslate::PromptTest < Minitest::Test 14 | def head(source, target) 15 | [ 16 | 'Please, translate the following Markdown paragraph', 17 | " from #{source} to #{target},", 18 | " don't translate technical terms and proper nouns" 19 | ].join 20 | end 21 | 22 | def test_english_to_russian 23 | assert_equal( 24 | "#{head('English', 'Russian')}:\n\nHello, dude, how are you doing today in this fair city?", 25 | GptTranslate::Prompt.new('Hello, dude, how are you doing today in this fair city?', 'en', 'ru').to_s 26 | ) 27 | end 28 | 29 | def test_english_to_chinese 30 | assert_equal( 31 | "#{head('English', 'Chinese')}: \"Hello, Jeff!\"", 32 | GptTranslate::Prompt.new('Hello, Jeff!', 'en', 'zh').to_s 33 | ) 34 | end 35 | 36 | def test_multiple_paragraphs 37 | assert_includes( 38 | GptTranslate::Prompt.new("Hello,\n\nJeff!", 'en', 'zh').to_s, "\"Hello,\n\nJeff!\"" 39 | ) 40 | end 41 | end 42 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # SPDX-FileCopyrightText: Copyright (c) 2023-2025 Yegor Bugayenko 4 | # SPDX-License-Identifier: MIT 5 | 6 | require 'rubygems' 7 | require 'rake' 8 | 9 | def name 10 | @name ||= File.basename(Dir['*.gemspec'].first, '.*') 11 | end 12 | 13 | def version 14 | Gem::Specification.load(Dir['*.gemspec'].first).version 15 | end 16 | 17 | require 'rake/clean' 18 | task default: %i[clean test features rubocop] 19 | 20 | require 'rake/testtask' 21 | desc 'Run all unit tests' 22 | Rake::TestTask.new(:test) do |test| 23 | Rake::Cleaner.cleanup_files(['coverage']) 24 | test.libs << 'lib' << 'test' 25 | test.pattern = 'test/**/test_*.rb' 26 | test.warning = true 27 | test.verbose = false 28 | end 29 | 30 | require 'rdoc' 31 | require 'rdoc/task' 32 | desc 'Build RDoc documentation' 33 | Rake::RDocTask.new do |rdoc| 34 | rdoc.rdoc_dir = 'rdoc' 35 | rdoc.title = "#{name} #{version}" 36 | rdoc.rdoc_files.include('README*') 37 | rdoc.rdoc_files.include('lib/**/*.rb') 38 | end 39 | 40 | require 'rubocop/rake_task' 41 | desc 'Run RuboCop on all directories' 42 | RuboCop::RakeTask.new(:rubocop) do |task| 43 | task.fail_on_error = true 44 | end 45 | 46 | require 'cucumber/rake/task' 47 | Cucumber::Rake::Task.new(:features) do 48 | Rake::Cleaner.cleanup_files(['coverage']) 49 | end 50 | Cucumber::Rake::Task.new(:'features:html') do |t| 51 | t.profile = 'html_report' 52 | end 53 | -------------------------------------------------------------------------------- /jekyll-chatgpt-translate.gemspec: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # SPDX-FileCopyrightText: Copyright (c) 2023-2025 Yegor Bugayenko 4 | # SPDX-License-Identifier: MIT 5 | 6 | require 'English' 7 | 8 | Gem::Specification.new do |s| 9 | s.required_rubygems_version = Gem::Requirement.new('>= 0') if s.respond_to? :required_rubygems_version= 10 | s.required_ruby_version = '>= 3.0' 11 | s.name = 'jekyll-chatgpt-translate' 12 | s.version = '0.0.0' 13 | s.license = 'MIT' 14 | s.summary = 'Translate Jekyll Pages Through ChatGPT' 15 | s.description = [ 16 | 'Add this plugin to your Jekyll site and all posts will be automatically', 17 | 'translated to the languages of your choice through ChatGPT' 18 | ].join(' ') 19 | s.authors = ['Yegor Bugayenko'] 20 | s.email = 'yegor256@gmail.com' 21 | s.homepage = 'https://github.com/yegor256/jekyll-chatgpt-translate' 22 | s.files = `git ls-files`.split($INPUT_RECORD_SEPARATOR) 23 | s.executables = s.files.grep(%r{^bin/}) { |f| File.basename(f) } 24 | s.rdoc_options = ['--charset=UTF-8'] 25 | s.extra_rdoc_files = %w[README.md LICENSE.txt] 26 | s.add_dependency 'humanize', '>= 2' 27 | s.add_dependency 'iri', '>= 0' 28 | s.add_dependency 'iso-639', '>= 0' 29 | s.add_dependency 'jekyll', '>= 3' 30 | s.add_dependency 'json', '>= 2' 31 | s.add_dependency 'redcarpet', '>= 3' 32 | s.add_dependency 'ruby-openai', '>= 5' 33 | s.add_dependency 'tiktoken_ruby', '>= 0.0.6' 34 | s.metadata['rubygems_mfa_required'] = 'true' 35 | end 36 | -------------------------------------------------------------------------------- /lib/jekyll-chatgpt-translate/ping.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # SPDX-FileCopyrightText: Copyright (c) 2023-2025 Yegor Bugayenko 4 | # SPDX-License-Identifier: MIT 5 | 6 | require 'iri' 7 | require 'net/http' 8 | require 'uri' 9 | require_relative 'version' 10 | 11 | # see https://stackoverflow.com/a/6048451/187141 12 | require 'openssl' 13 | OpenSSL::SSL::VERIFY_PEER = OpenSSL::SSL::VERIFY_NONE 14 | 15 | # The module we are in. 16 | module GptTranslate; end 17 | 18 | # Ping one page of a site. 19 | # Author:: Yegor Bugayenko (yegor256@gmail.com) 20 | # Copyright:: Copyright (c) 2023-2025 Yegor Bugayenko 21 | # License:: MIT 22 | class GptTranslate::Ping 23 | # Ctor. 24 | def initialize(site, path) 25 | @site = site 26 | raise 'Permalink must start with a slash' unless path.start_with?('/') 27 | @path = path 28 | end 29 | 30 | # Downloads the page from the Internet and returns HTML or NIL, if the page is absent 31 | def download 32 | home = @site.config['url'] 33 | return nil if home.nil? 34 | uri = Iri.new(home).path(@path).to_s 35 | html = nil 36 | begin 37 | response = Net::HTTP.get_response(URI(uri)) 38 | html = response.body if response.is_a?(Net::HTTPSuccess) 39 | Jekyll.logger.debug("GET #{uri.inspect}: #{response.code}") 40 | rescue Errno::ECONNREFUSED, Errno::EADDRNOTAVAIL => e 41 | Jekyll.logger.debug("Failed to ping #{uri.inspect} (#{e.class.name}): #{e.message}") 42 | end 43 | html 44 | end 45 | end 46 | -------------------------------------------------------------------------------- /test/test_ping.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # SPDX-FileCopyrightText: Copyright (c) 2023-2025 Yegor Bugayenko 4 | # SPDX-License-Identifier: MIT 5 | 6 | require 'webmock/minitest' 7 | require 'jekyll' 8 | require 'tempfile' 9 | require_relative 'test__helper' 10 | require_relative '../lib/jekyll-chatgpt-translate/ping' 11 | 12 | # Ping test. 13 | # Author:: Yegor Bugayenko (yegor256@gmail.com) 14 | # Copyright:: Copyright (c) 2023-2025 Yegor Bugayenko 15 | # License:: MIT 16 | class GptTranslate::PingTest < Minitest::Test 17 | def test_when_exists 18 | stub_request(:any, 'https://www.yegor256.com/about-me.html').to_return(body: 'Hello!') 19 | site = GptTranslate::FakeSite.new({ 'url' => 'https://www.yegor256.com/' }) 20 | ping = GptTranslate::Ping.new(site, '/about-me.html') 21 | refute_nil(ping.download) 22 | end 23 | 24 | def test_when_not_exists 25 | stub_request(:any, 'https://www.yegor256.com/absent.html').to_return(status: 404) 26 | site = GptTranslate::FakeSite.new({ 'url' => 'https://www.yegor256.com/' }) 27 | ping = GptTranslate::Ping.new(site, '/absent.html') 28 | assert_nil(ping.download) 29 | end 30 | 31 | def test_wrong_address 32 | WebMock.allow_net_connect! 33 | site = GptTranslate::FakeSite.new({ 'url' => 'https://localhost:1/' }) 34 | ping = GptTranslate::Ping.new(site, '/boom.html') 35 | assert_nil(ping.download) 36 | end 37 | 38 | def test_relative_path 39 | assert_raises(StandardError) do 40 | GptTranslate::Ping.new({}, '404.html') 41 | end 42 | end 43 | end 44 | -------------------------------------------------------------------------------- /lib/jekyll-chatgpt-translate/prompt.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # SPDX-FileCopyrightText: Copyright (c) 2023-2025 Yegor Bugayenko 4 | # SPDX-License-Identifier: MIT 5 | 6 | require 'iso-639' 7 | require 'humanize' 8 | 9 | # The module we are in. 10 | module GptTranslate; end 11 | 12 | # Prompt for ChatGPT. 13 | # Author:: Yegor Bugayenko (yegor256@gmail.com) 14 | # Copyright:: Copyright (c) 2023-2025 Yegor Bugayenko 15 | # License:: MIT 16 | class GptTranslate::Prompt 17 | # Ctor. 18 | # +par+ Text to translate 19 | # +source+ The language to translate from 20 | # +target+ The language to translate into 21 | def initialize(par, source, target) 22 | @par = par 23 | @source = source 24 | @target = target 25 | end 26 | 27 | def to_s 28 | from = ISO_639.find_by_code(@source) 29 | raise "Unknown source language ISO-639 code: #{@source.inspect}" if from.nil? 30 | to = ISO_639.find_by_code(@target) 31 | raise "Unknown source language ISO-639 code: #{@target.inspect}" if to.nil? 32 | md = @par 33 | parts = md.split("\n\n") 34 | label = parts.size > 1 ? "#{parts.size.humanize(locale: :en)} Markdown paragraphs" : 'Markdown paragraph' 35 | head = [ 36 | "Please, translate the following #{label} from ", 37 | from[3], 38 | ' to ', 39 | to[3], 40 | ', don\'t translate technical terms and proper nouns' 41 | ].join 42 | if @par.include?('"') || @par.split.count >= 8 43 | "#{head}:\n\n#{@par}" 44 | else 45 | "#{head}: \"#{@par}\"" 46 | end 47 | end 48 | end 49 | -------------------------------------------------------------------------------- /test/test_pars.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # SPDX-FileCopyrightText: Copyright (c) 2023-2025 Yegor Bugayenko 4 | # SPDX-License-Identifier: MIT 5 | 6 | require_relative 'test__helper' 7 | require_relative '../lib/jekyll-chatgpt-translate/pars' 8 | 9 | # Test for Pars. 10 | # Author:: Yegor Bugayenko (yegor256@gmail.com) 11 | # Copyright:: Copyright (c) 2023-2025 Yegor Bugayenko 12 | # License:: MIT 13 | class GptTranslate::ParsTest < Minitest::Test 14 | def test_simple_cases 15 | assert_equal(1, GptTranslate::Pars.new('Hello, **world**!').to_a.size) 16 | assert_equal(2, GptTranslate::Pars.new("Hello,\n\n**world**!").to_a.size) 17 | assert_equal(2, GptTranslate::Pars.new("\n\n\nHello,\n\n**world**\n!\n\n").to_a.size) 18 | end 19 | 20 | def test_returns_unfrozen_strings 21 | GptTranslate::Pars.new("Hi, world!\n\n```\ntest\n```\n\nBye\n").to_a.map(&:strip!) 22 | end 23 | 24 | def test_understands_code_block 25 | pars = GptTranslate::Pars.new("Hello:\n\n```java\na\n\nb\n\nc\n```\n\nz").to_a 26 | assert_equal(3, pars.size) 27 | assert_equal('Hello:', pars[0]) 28 | assert_equal("```java\na\n\nb\n\nc\n```", pars[1]) 29 | assert_equal('z', pars[2]) 30 | end 31 | 32 | def test_understands_empty_block 33 | pars = GptTranslate::Pars.new("Hello:\n\n```\n```\n\nz").to_a 34 | assert_equal(3, pars.size) 35 | assert_equal('Hello:', pars[0]) 36 | assert_equal("```\n```", pars[1]) 37 | assert_equal('z', pars[2]) 38 | end 39 | 40 | def test_understands_empty_block_with_type 41 | pars = GptTranslate::Pars.new("Hello:\n\n```java\n```\n\nz").to_a 42 | assert_equal(3, pars.size) 43 | assert_equal('Hello:', pars[0]) 44 | assert_equal("```java\n```", pars[1]) 45 | assert_equal('z', pars[2]) 46 | end 47 | 48 | def test_understands_two_blocks 49 | pars = GptTranslate::Pars.new("```java\na\n\nb\n```\n\n```text\na\n\nb\n```").to_a 50 | assert_equal(2, pars.size) 51 | assert_equal("```java\na\n\nb\n```", pars[0]) 52 | assert_equal("```text\na\n\nb\n```", pars[1]) 53 | end 54 | end 55 | -------------------------------------------------------------------------------- /features/step_definitions/steps.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # SPDX-FileCopyrightText: Copyright (c) 2023-2025 Yegor Bugayenko 4 | # SPDX-License-Identifier: MIT 5 | 6 | require 'tmpdir' 7 | require 'English' 8 | 9 | Before do 10 | @cwd = Dir.pwd 11 | @dir = Dir.mktmpdir('test') 12 | FileUtils.mkdir_p(@dir) 13 | Dir.chdir(@dir) 14 | end 15 | 16 | After do 17 | Dir.chdir(@cwd) 18 | FileUtils.rm_rf(@dir) 19 | end 20 | 21 | Given(/^I have a "([^"]*)" file with content:$/) do |file, text| 22 | FileUtils.mkdir_p(File.dirname(file)) unless File.exist?(file) 23 | File.write(file, text.gsub('\\xFF', 0xFF.chr)) 24 | end 25 | 26 | When('I build Jekyll site') do 27 | @stdout = `jekyll build` 28 | @exitstatus = $CHILD_STATUS.exitstatus 29 | end 30 | 31 | Then('Stdout contains {string}') do |string| 32 | raise "STDOUT doesn't contain '#{string}':\n#{@stdout}" unless @stdout.include?(string) 33 | end 34 | 35 | Then('File {string} exists') do |string| 36 | raise "The file \"#{string}\" is absent:\n#{`tree -s`}" unless File.exist?(string) 37 | end 38 | 39 | Then('File {string} doesn\'t exist') do |string| 40 | raise "The file \"#{string}\" is present:\n#{`tree -s`}" if File.exist?(string) 41 | end 42 | 43 | Then('File {string} contains {string}') do |string, string2| 44 | raise "The file \"#{string}\" is absent" unless File.exist?(string) 45 | content = File.read(string) 46 | raise "The file \"#{string}\" doesn't contain \"#{string2}\":\n#{content}" unless content.include?(string2) 47 | end 48 | 49 | Then('Exit code is zero') do 50 | raise "Non-zero exit #{@exitstatus}:\n#{@stdout}" unless @exitstatus.zero? 51 | end 52 | 53 | Then('Exit code is not zero') do 54 | raise 'Zero exit code' if @exitstatus.zero? 55 | end 56 | 57 | When('I run bash with {string}') do |string| 58 | @stdout = `#{string}` 59 | @exitstatus = $CHILD_STATUS.exitstatus 60 | end 61 | 62 | When(/^I run bash with:$/) do |text| 63 | @stdout = `#{text}` 64 | @exitstatus = $CHILD_STATUS.exitstatus 65 | end 66 | 67 | When('I copy this gem into temp dir') do 68 | FileUtils.copy_entry(@cwd, File.join(@dir, 'jekyll-chatgpt-translate')) 69 | end 70 | 71 | Given('It is Unix') do 72 | pending if Gem.win_platform? 73 | end 74 | 75 | Given('It is Windows') do 76 | pending unless Gem.win_platform? 77 | end 78 | -------------------------------------------------------------------------------- /test/test_generator.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # SPDX-FileCopyrightText: Copyright (c) 2023-2025 Yegor Bugayenko 4 | # SPDX-License-Identifier: MIT 5 | 6 | require 'tmpdir' 7 | require 'webmock/minitest' 8 | require_relative 'test__helper' 9 | require_relative '../lib/jekyll-chatgpt-translate/generator' 10 | 11 | # Generator test. 12 | # Author:: Yegor Bugayenko (yegor256@gmail.com) 13 | # Copyright:: Copyright (c) 2023-2025 Yegor Bugayenko 14 | # License:: MIT 15 | class GptTranslate::GeneratorTest < Minitest::Test 16 | def test_simple_scenario 17 | Dir.mktmpdir do |home| 18 | post = File.join(home, '2023-01-01-hello.md') 19 | File.write(post, "---\ntitle: Hello\n---\n\nHello, world!") 20 | site = GptTranslate::FakeSite.new( 21 | { 22 | 'url' => 'https://www.yegor256.com/', 23 | 'chatgpt-translate' => { 24 | 'targets' => [ 25 | { 26 | 'language' => 'zh', 27 | 'layout' => 'chinese', 28 | 'permalink' => ':slug.html' 29 | } 30 | ] 31 | } 32 | }, 33 | [post] 34 | ) 35 | gen = GptTranslate::Generator.new 36 | stub_request(:get, 'https://www.yegor256.com/.html').to_return(body: '') 37 | gen.generate(site) 38 | assert_equal(1, site.pages.count) 39 | end 40 | end 41 | 42 | def test_threshold_stops 43 | Dir.mktmpdir do |home| 44 | post = File.join(home, '2023-01-01-hello.md') 45 | File.write(post, "---\ntitle: Hello\n---\n\nHello, world!") 46 | site = GptTranslate::FakeSite.new( 47 | { 48 | 'chatgpt-translate' => { 49 | 'threshold' => 1, 50 | 'targets' => [ 51 | { 52 | 'language' => 'zh', 53 | 'permalink' => ':slug.html' 54 | }, 55 | { 56 | 'language' => 'fr', 57 | 'permalink' => ':year/:slug.html' 58 | } 59 | ] 60 | } 61 | }, 62 | [post, post] 63 | ) 64 | gen = GptTranslate::Generator.new 65 | stub_request(:get, 'https://www.yegor256.com/.html').to_return(body: '') 66 | gen.generate(site) 67 | assert_equal(1, site.pages.count) 68 | end 69 | end 70 | end 71 | -------------------------------------------------------------------------------- /lib/jekyll-chatgpt-translate/plain.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # SPDX-FileCopyrightText: Copyright (c) 2023-2025 Yegor Bugayenko 4 | # SPDX-License-Identifier: MIT 5 | 6 | require 'redcarpet' 7 | 8 | # The module we are in. 9 | module GptTranslate; end 10 | 11 | # Markdown to plain text. 12 | # Author:: Yegor Bugayenko (yegor256@gmail.com) 13 | # Copyright:: Copyright (c) 2023-2025 Yegor Bugayenko 14 | # License:: MIT 15 | class GptTranslate::Plain 16 | # Ctor. 17 | def initialize(markdown) 18 | @markdown = markdown 19 | end 20 | 21 | # Liquid tags are removed, but this implementation is primitive 22 | # Seehttps://stackoverflow.com/questions/ 23 | def to_s 24 | Redcarpet::Markdown.new(Strip).render( 25 | @markdown 26 | .gsub(/([^\n])\n(\s*\* )/, "\\1\n\n\\2") # condensed list into item-per-par 27 | .gsub(//m, '') 28 | .gsub(/{{[^}]+}}/, '') 29 | .gsub(/{%.+?%}/, '') 30 | .gsub(/^\{.+?\}\n/, '') 31 | .gsub(/\n\{.+?\}$/, '') 32 | ).strip 33 | end 34 | 35 | # Markdown to pain text. 36 | # Motivated by https://github.com/vmg/redcarpet/blob/master/lib/redcarpet/render_strip.rb 37 | class Strip < Redcarpet::Render::Base 38 | %i[ 39 | autolink 40 | underline 41 | triple_emphasis 42 | strikethrough 43 | superscript highlight quote 44 | footnotes footnote_def footnote_ref 45 | entity normal_text 46 | ].each do |method| 47 | define_method method do |*args| 48 | args.first 49 | end 50 | end 51 | 52 | def double_emphasis(txt) 53 | "**#{txt}**" 54 | end 55 | 56 | def block_code(code, _lang) 57 | code 58 | end 59 | 60 | def block_quote(txt) 61 | "> #{txt}" 62 | end 63 | 64 | def emphasis(txt) 65 | "*#{txt}*" 66 | end 67 | 68 | def header(text, level) 69 | "#{'#' * level} #{text}\n\n" 70 | end 71 | 72 | def codespan(content) 73 | if content.start_with?("\n") 74 | "```#{content}```" 75 | elsif content.end_with?("\n") 76 | "```\n#{content.split("\n", 2)[1]}```" 77 | else 78 | "`#{content}`" 79 | end 80 | end 81 | 82 | def image(link, title, alt) 83 | "![#{alt}](#{link} \"#{title}\")" 84 | end 85 | 86 | def block_html(html) 87 | "#{html}\n" 88 | end 89 | 90 | def raw_html(html) 91 | html 92 | end 93 | 94 | def list(content, _type) 95 | content 96 | end 97 | 98 | def list_item(content, type) 99 | "#{type == :ordered ? '1.' : '*'} #{content.strip}\n\n" 100 | end 101 | 102 | def paragraph(text) 103 | unless text.start_with?('```') 104 | text.gsub!(/\n+/, ' ') 105 | text.gsub!(/\s{2,}/, ' ') 106 | end 107 | "#{text}\n\n" 108 | end 109 | 110 | def link(link, _title, content) 111 | if !link.nil? && link.start_with?('/', 'https://', 'http://') 112 | "[#{content}](#{link})" 113 | else 114 | content 115 | end 116 | end 117 | end 118 | end 119 | -------------------------------------------------------------------------------- /test/test__helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # SPDX-FileCopyrightText: Copyright (c) 2023-2025 Yegor Bugayenko 4 | # SPDX-License-Identifier: MIT 5 | 6 | $stdout.sync = true 7 | 8 | require 'simplecov' 9 | require 'simplecov-cobertura' 10 | unless SimpleCov.running 11 | SimpleCov.command_name('test') 12 | SimpleCov.formatter = SimpleCov::Formatter::MultiFormatter.new( 13 | [ 14 | SimpleCov::Formatter::HTMLFormatter, 15 | SimpleCov::Formatter::CoberturaFormatter 16 | ] 17 | ) 18 | SimpleCov.minimum_coverage 85 19 | SimpleCov.minimum_coverage_by_file 80 20 | SimpleCov.start do 21 | add_filter 'test/' 22 | add_filter 'vendor/' 23 | add_filter 'target/' 24 | track_files 'lib/**/*.rb' 25 | track_files '*.rb' 26 | end 27 | end 28 | 29 | require 'minitest/reporters' 30 | Minitest::Reporters.use! [Minitest::Reporters::SpecReporter.new] 31 | Minitest.load :minitest_reporter 32 | 33 | require 'minitest/autorun' 34 | 35 | require 'jekyll' 36 | Jekyll.logger.adjust_verbosity(verbose: false) 37 | 38 | # The module we are in. 39 | module GptTranslate; end 40 | 41 | # Fake. 42 | # Author:: Yegor Bugayenko (yegor256@gmail.com) 43 | # Copyright:: Copyright (c) 2023-2025 Yegor Bugayenko 44 | # License:: MIT 45 | class GptTranslate::FakeSite 46 | attr_reader :config, :pages, :static_files 47 | 48 | def initialize(config, docs = []) 49 | @config = config 50 | @docs = docs 51 | @pages = [] 52 | @static_files = [] 53 | end 54 | 55 | def posts 56 | GptTranslate::FakePosts.new(@docs) 57 | end 58 | 59 | def permalink_style 60 | '' 61 | end 62 | 63 | def frontmatter_defaults 64 | Jekyll::FrontmatterDefaults.new(self) 65 | end 66 | 67 | def converters 68 | [Jekyll::Converters::Markdown.new({ 'markdown_ext' => 'md' })] 69 | end 70 | 71 | def source 72 | '' 73 | end 74 | 75 | def dest 76 | return '' if @docs.empty? 77 | File.dirname(@docs[0]) 78 | end 79 | 80 | def in_theme_dir(base, _foo = nil, _bar = nil) 81 | base 82 | end 83 | 84 | def in_dest_dir(*paths) 85 | paths[0].dup 86 | end 87 | end 88 | 89 | # Fake. 90 | # Author:: Yegor Bugayenko (yegor256@gmail.com) 91 | # Copyright:: Copyright (c) 2023-2025 Yegor Bugayenko 92 | # License:: MIT 93 | class GptTranslate::FakeDocument 94 | attr_reader :data 95 | 96 | def initialize(path) 97 | @path = path 98 | @data = { 'date' => Time.now, 'title' => 'Hello!' } 99 | end 100 | 101 | def content 102 | 'Hello, world!' 103 | end 104 | 105 | def []=(key, value) 106 | @data[key] = value 107 | end 108 | 109 | def [](key) 110 | @data[key] || '' 111 | end 112 | 113 | def relative_path 114 | @path 115 | end 116 | 117 | def url 118 | '2023-01-01-hello.html' 119 | end 120 | 121 | def basename 122 | '2023-01-01-hello.md' 123 | end 124 | end 125 | 126 | # Fake. 127 | # Author:: Yegor Bugayenko (yegor256@gmail.com) 128 | # Copyright:: Copyright (c) 2023-2025 Yegor Bugayenko 129 | # License:: MIT 130 | class GptTranslate::FakePosts 131 | attr_reader :config 132 | 133 | def initialize(docs) 134 | @docs = docs 135 | end 136 | 137 | def docs 138 | @docs.map { |d| GptTranslate::FakeDocument.new(d) } 139 | end 140 | end 141 | -------------------------------------------------------------------------------- /test/test_chatgpt.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # SPDX-FileCopyrightText: Copyright (c) 2023-2025 Yegor Bugayenko 4 | # SPDX-License-Identifier: MIT 5 | 6 | require 'webmock/minitest' 7 | require_relative 'test__helper' 8 | require_relative '../lib/jekyll-chatgpt-translate/chatgpt' 9 | 10 | # ChatGPT test. 11 | # Author:: Yegor Bugayenko (yegor256@gmail.com) 12 | # Copyright:: Copyright (c) 2023-2025 Yegor Bugayenko 13 | # License:: MIT 14 | class GptTranslate::ChatGPTTest < Minitest::Test 15 | def test_short_text 16 | chat = GptTranslate::ChatGPT.new('fake-key', 'foo', 'xx', 'zz') 17 | assert_equal('Hello, world!', chat.translate('Hello, world!')) 18 | end 19 | 20 | def test_start_with_link 21 | stub_it! 22 | chat = GptTranslate::ChatGPT.new('fake-key', 'gpt-3.5-turbo', 'en', 'ru') 23 | assert_equal('done!', chat.translate('[OpenAI](https://openai.com) is the creator of ChatGPT', min: 10)) 24 | end 25 | 26 | def test_unordered_list_item 27 | stub_it! 28 | chat = GptTranslate::ChatGPT.new('fake-key', 'gpt-3.5-turbo', 'en', 'ru') 29 | assert_equal("* done!\n\n* done!", chat.translate("* First\n\n* Second", min: 1)) 30 | end 31 | 32 | def test_ordered_list_item 33 | stub_it! 34 | chat = GptTranslate::ChatGPT.new('fake-key', 'gpt-3.5-turbo', 'en', 'ru') 35 | assert_equal("1. done!\n\n1. done!", chat.translate("1. First\n\n2. Second", min: 1)) 36 | end 37 | 38 | def test_dry_mode 39 | chat = GptTranslate::ChatGPT.new('', 'foo', 'xx', 'zz') 40 | assert_equal(38, chat.translate('This text should not be sent to OpenAI', min: 100).length) 41 | end 42 | 43 | def test_no_translation 44 | chat = GptTranslate::ChatGPT.new('', 'foo', 'xx', 'zz') 45 | chat.translate( 46 | " 47 | How are you, my friend? This text must be translated through ChatGPT. 48 | 49 | Read this Java code (this paragraph must also be translated through ChatGPT): 50 | 51 | ``` 52 | System.out.println(\"Hello, dude!\"); 53 | System.out.println(\"Good bye!\"); 54 | System.out.println(\"Done!\"); 55 | ``` 56 | 57 | This is it. 58 | ", 59 | min: 40, 60 | window_length: 10 61 | ) 62 | end 63 | 64 | def test_markup 65 | chat = GptTranslate::ChatGPT.new('fake-key', 'gpt-3.5-turbo', 'xx', 'zz') 66 | assert_equal('', chat.translate('', min: 1)) 67 | end 68 | 69 | def test_image 70 | chat = GptTranslate::ChatGPT.new('fake-key', 'gpt-3.5-turbo', 'xx', 'zz') 71 | assert_equal('![some image](/foo.png)', chat.translate('![some image](/foo.png)', min: 1)) 72 | end 73 | 74 | def test_code_block 75 | chat = GptTranslate::ChatGPT.new('fake-key', '', 'xx', 'zz') 76 | chat.translate("```\ntest\n```", min: 0) 77 | end 78 | 79 | def test_through_webmock 80 | stub_it! 81 | chat = GptTranslate::ChatGPT.new('fake-key', 'gpt-3.5-turbo', 'en', 'ru') 82 | assert_equal('done!', chat.translate('This is the text to send to OpenAI')) 83 | end 84 | 85 | def test_through_small_window 86 | stub_it! 87 | chat = GptTranslate::ChatGPT.new('fake-key', 'gpt-3.5-turbo', 'en', 'ru') 88 | assert_equal( 89 | "done!\n\ndone!", 90 | chat.translate( 91 | "This is the first paragraph\n\nThis is second\n\nThis is third", 92 | min: 1, window_length: 4 93 | ) 94 | ) 95 | end 96 | 97 | def test_with_json 98 | client = Object.new 99 | def client.chat(*) 100 | { 'choices' => [{ 'message' => { 'content' => 'done!' } }] } 101 | end 102 | chat = GptTranslate::ChatGPT.new('fake-key', 'gpt-3.5-turbo', 'en', 'ru', client: client) 103 | assert_equal( 104 | "done!\n\ndone!", 105 | chat.translate( 106 | "This is the first paragraph\n\nThis is second\n\nThis is third", 107 | min: 1, window_length: 4 108 | ) 109 | ) 110 | end 111 | 112 | private 113 | 114 | def stub_it! 115 | url = "#{api_base_url}v1/chat/completions" 116 | stub_request(:any, url).to_return( 117 | body: '{"choices":[{"message":{"content": "done!"}}]}' 118 | ) 119 | end 120 | 121 | def api_base_url 122 | url = ENV.fetch('OPENAI_API_BASE', 'https://api.openai.com/') 123 | Jekyll.logger.info("Current OpenAI API Base URL: #{url.inspect}") 124 | 125 | warning_msg = 'Warning: You\'re using a custom endpoint for the OpenAI API. ' \ 126 | 'The provider of this endpoint may have access to all details ' \ 127 | 'of your requests. Only use a custom endpoint if you trust the provider.' 128 | Jekyll.logger.warn(warning_msg) if url != 'https://api.openai.com/' 129 | 130 | url 131 | end 132 | end 133 | -------------------------------------------------------------------------------- /lib/jekyll-chatgpt-translate/chatgpt.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # SPDX-FileCopyrightText: Copyright (c) 2023-2025 Yegor Bugayenko 4 | # SPDX-License-Identifier: MIT 5 | 6 | require 'jekyll' 7 | require 'json' 8 | require 'openai' 9 | require 'iso-639' 10 | require 'tiktoken_ruby' 11 | require_relative 'pars' 12 | require_relative 'prompt' 13 | 14 | # The module we are in. 15 | module GptTranslate; end 16 | 17 | # Abstraction of ChatGPT. 18 | # Author:: Yegor Bugayenko (yegor256@gmail.com) 19 | # Copyright:: Copyright (c) 2023-2025 Yegor Bugayenko 20 | # License:: MIT 21 | class GptTranslate::ChatGPT 22 | # Is TRUE if we already shown to the user the available models. 23 | @@models_printed = false 24 | 25 | # Ctor. 26 | # +key+ OpenAI API Key, which can't be nil, but can be empty string, which means dry mode (no calls to OpenAI) 27 | # +source+ The language to translate from 28 | # +target+ The language to translate into 29 | def initialize(key, model, source, target, client: OpenAI::Client.new(access_token: key, uri_base: api_base_url)) 30 | raise 'OpenAI key cannot be nil' if key.nil? 31 | @key = key 32 | @model = model 33 | @source = source 34 | @target = target 35 | @client = client 36 | end 37 | 38 | def api_base_url 39 | url = ENV.fetch('OPENAI_API_BASE', 'https://api.openai.com/') 40 | Jekyll.logger.info("Current OpenAI API Base URL: #{url.inspect}") 41 | unless url == 'https://api.openai.com/' 42 | Jekyll.logger.warn( 43 | 'Warning: You\'re using a custom endpoint for the OpenAI API. ' \ 44 | 'The provider of this endpoint may have access to all details ' \ 45 | 'of your requests. Only use a custom endpoint if you trust the provider.' 46 | ) 47 | end 48 | url 49 | end 50 | 51 | def translate(markdown, min: 32, window_length: 2000) 52 | pars = GptTranslate::Pars.new(markdown).to_a 53 | ready = [] 54 | later = [] 55 | pars.each_with_index do |pa, i| 56 | par = pa.dup 57 | par.strip! 58 | if @source == @target 59 | Jekyll.logger.debug("No need to translate from #{@source.inspect} to itself: #{par.inspect}") 60 | ready[i] = par 61 | elsif par.length < min 62 | Jekyll.logger.debug("Not translating this, b/c too short: #{par.inspect}") 63 | ready[i] = par 64 | elsif par.start_with?('```') 65 | Jekyll.logger.debug("Not translating this code block: #{par.inspect}") 66 | ready[i] = par 67 | elsif @key.empty? 68 | ready[i] = par 69 | elsif par.start_with?('> ') 70 | ready[i] = "> #{translate_par(par[2..])}" 71 | elsif par.start_with?('* ') 72 | ready[i] = "* #{translate_par(par[2..])}" 73 | elsif /^[0-9]+\. /.match?(par) 74 | ready[i] = "1. #{translate_par(par.split('.', 2)[1])}" 75 | elsif /^[^\p{Alnum}\*'"\[]/.match?(par) 76 | Jekyll.logger.debug("Not translating this, b/c it's not a plain text: #{par.inspect}") 77 | ready[i] = par 78 | else 79 | later[i] = par 80 | end 81 | end 82 | out = [] 83 | i = 0 84 | while i < pars.length 85 | unless ready[i].nil? 86 | out << ready[i] 87 | i += 1 88 | next 89 | end 90 | accum = [] 91 | until later[i].nil? 92 | already = Tiktoken.encoding_for_model('gpt-4').encode(accum.join).length 93 | if already > window_length 94 | Jekyll.logger.debug("Already #{already} words, over the window_length of #{window_length}") 95 | break 96 | end 97 | accum << later[i] 98 | i += 1 99 | end 100 | out << translate_pars(accum) 101 | i += 1 102 | end 103 | out.join("\n\n") 104 | end 105 | 106 | private 107 | 108 | def translate_pars(accum) 109 | translate_par(accum.join("\n\n")) 110 | end 111 | 112 | def translate_par(par) 113 | if @@models_printed 114 | Jekyll.logger.info("Available ChatGPT models: #{@client.models.list['data'].map { |m| m['id'] }.join(', ')}") 115 | @@models_printed = true 116 | end 117 | prompt = GptTranslate::Prompt.new(par, @source, @target).to_s 118 | start = Time.now 119 | answer = nil 120 | attempt = 0 121 | begin 122 | response = @client.chat( 123 | parameters: { 124 | model: @model, 125 | messages: [{ role: 'user', content: prompt }], 126 | temperature: 0.7 127 | } 128 | ) 129 | json = response.is_a?(Hash) ? response : JSON.parse(response) 130 | answer = json.dig('choices', 0, 'message', 'content') 131 | if answer.nil? 132 | Jekyll.logger.error("No content returned by ChatGPT: #{response}") 133 | raise 'No content returned by ChatGPT' 134 | end 135 | Jekyll.logger.debug("ChatGPT prompt: #{prompt.inspect}, ChatGPT answer: #{answer.inspect}") 136 | rescue StandardError => e 137 | attempt += 1 138 | if attempt < 4 139 | Jekyll.logger.error( 140 | "ChatGPT failed to answer to #{prompt.inspect}" \ 141 | "(attempt no.#{attempt}): #{e.message.inspect}" 142 | ) 143 | retry 144 | end 145 | raise e 146 | end 147 | Jekyll.logger.info( 148 | "Translated #{par.split.count} #{@source.upcase} words " \ 149 | "to #{answer.split.count} #{@target.upcase} words " \ 150 | "through #{@model} in #{(Time.now - start).round(2)}s: #{"#{par[0..24]}...".inspect}" 151 | ) 152 | answer 153 | end 154 | end 155 | -------------------------------------------------------------------------------- /test/test_plain.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # SPDX-FileCopyrightText: Copyright (c) 2023-2025 Yegor Bugayenko 4 | # SPDX-License-Identifier: MIT 5 | 6 | require_relative 'test__helper' 7 | require_relative '../lib/jekyll-chatgpt-translate/plain' 8 | 9 | # Plain test. 10 | # Author:: Yegor Bugayenko (yegor256@gmail.com) 11 | # Copyright:: Copyright (c) 2023-2025 Yegor Bugayenko 12 | # License:: MIT 13 | class GptTranslate::PlainTest < Minitest::Test 14 | def test_simple_map 15 | assert_equal('Hello, **world**!', GptTranslate::Plain.new("Hello,\n**world**!").to_s) 16 | assert_equal('Hello, [world](/x.html)!', GptTranslate::Plain.new("Hello,\n[world](/x.html)!").to_s) 17 | assert_equal('Hello, *Jeff*!', GptTranslate::Plain.new('Hello, _Jeff_!').to_s) 18 | # assert_equal('Hello, Walter!', GptTranslate::Plain.new('Hello, ~Walter~!').to_s) 19 | assert_equal("Hi\n\nBye", GptTranslate::Plain.new(" Hi\n\nBye\n\n\n").to_s) 20 | assert_equal('Hi, dude!', GptTranslate::Plain.new(" Hi,\ndude!\n").to_s) 21 | end 22 | 23 | def test_strip_meta_markup 24 | assert_equal('Hello, world!', GptTranslate::Plain.new("{:name='boom'}\nHello, world!").to_s) 25 | assert_equal('Hello, world!', GptTranslate::Plain.new("Hello, world!\n{: .foo-class}").to_s) 26 | end 27 | 28 | def test_lists 29 | assert_equal( 30 | "* first\n\n* second\n\n* third", 31 | GptTranslate::Plain.new("* first\n\n* second\n\n* third").to_s 32 | ) 33 | assert_equal( 34 | '* first', 35 | GptTranslate::Plain.new("* first\n\n\n\n").to_s 36 | ) 37 | end 38 | 39 | def test_ordered_list 40 | assert_equal( 41 | "1. first\n\n1. second\n\n1. third", 42 | GptTranslate::Plain.new("1. first\n\n2. second\n\n3. third").to_s 43 | ) 44 | end 45 | 46 | def test_compact_list 47 | assert_equal( 48 | "* first\n\n* second\n\n* third", 49 | GptTranslate::Plain.new("* first\n* second\n* third").to_s 50 | ) 51 | end 52 | 53 | def test_links 54 | assert_equal( 55 | 'Hello, [dude](/a.html)!', 56 | GptTranslate::Plain.new('Hello, [dude](/a.html)!').to_s 57 | ) 58 | assert_equal( 59 | 'Hello, dude!', 60 | GptTranslate::Plain.new('Hello, [dude]()!').to_s 61 | ) 62 | assert_equal( 63 | 'Hello, dude!', 64 | GptTranslate::Plain.new('Hello, [dude]({% post_url 2023-01-01-hello %})!').to_s 65 | ) 66 | end 67 | 68 | def test_quote 69 | assert_equal( 70 | "He said this:\n\n> Life is great!", 71 | GptTranslate::Plain.new("He said this:\n\n\n> Life is great!\n\n").to_s 72 | ) 73 | end 74 | 75 | def test_code 76 | assert_equal( 77 | 'Hello, `Java`!', 78 | GptTranslate::Plain.new('Hello, `Java`!').to_s 79 | ) 80 | end 81 | 82 | def test_code_block 83 | assert_equal( 84 | "```\na\na\na\na\na\na\na\n\n```", 85 | GptTranslate::Plain.new("```\na\na\na\na\na\na\na\n\n```").to_s 86 | ) 87 | assert_equal( 88 | "Hello:\n\n```\nJava\n```", 89 | GptTranslate::Plain.new("Hello:\n\n```\nJava\n```\n").to_s 90 | ) 91 | assert_equal( 92 | "```\nHello\n```", 93 | GptTranslate::Plain.new("```\nHello\n```").to_s 94 | ) 95 | assert_equal( 96 | "```\nprint('hi!')\n```", 97 | GptTranslate::Plain.new("```java\nprint('hi!')\n```").to_s 98 | ) 99 | end 100 | 101 | def test_code_block_with_empty_lines_inside 102 | assert_equal( 103 | "```\n\nhello\n\nworld!\n\n```", 104 | GptTranslate::Plain.new("```\n\nhello\n\n\n\nworld!\n\n```").to_s 105 | ) 106 | end 107 | 108 | def test_titles 109 | assert_equal('# Hello', GptTranslate::Plain.new('# Hello').to_s) 110 | assert_equal('## Hello', GptTranslate::Plain.new('## Hello').to_s) 111 | assert_equal('### Hello', GptTranslate::Plain.new('### Hello').to_s) 112 | end 113 | 114 | def test_image 115 | assert_equal('![alt](a.png "hello")', GptTranslate::Plain.new('![alt](a.png "hello")').to_s) 116 | end 117 | 118 | def test_html 119 | assert_equal( 120 | 'This is picture: !', 121 | GptTranslate::Plain.new('This is picture: !').to_s 122 | ) 123 | assert_equal('', GptTranslate::Plain.new('').to_s) 124 | end 125 | 126 | def test_html_hr 127 | md = "First\n\n
\n\nsecond!" 128 | assert_equal(md, GptTranslate::Plain.new(md).to_s) 129 | end 130 | 131 | def test_liquid_tags 132 | assert_equal( 133 | 'Hello, !', 134 | GptTranslate::Plain.new('Hello, {{ Java }}!').to_s 135 | ) 136 | assert_equal( 137 | 'Hello, dude !', 138 | GptTranslate::Plain.new('Hello, {% if a %} dude {% endif %}!').to_s 139 | ) 140 | assert_equal( 141 | 'Hello, !', 142 | GptTranslate::Plain.new('Hello, {% Java %}!').to_s 143 | ) 144 | assert_equal( 145 | 'Hello, !', 146 | GptTranslate::Plain.new('Hello, {% plantuml "width=50%" %}!').to_s 147 | ) 148 | end 149 | 150 | def test_html_comments 151 | assert_equal( 152 | 'Hello, !', 153 | GptTranslate::Plain.new('Hello, !').to_s 154 | ) 155 | assert_equal( 156 | 'Hello, !', 157 | GptTranslate::Plain.new("Hello, !").to_s 158 | ) 159 | end 160 | 161 | def test_big_text 162 | expected = "Hi, dear **friend**! 163 | 164 | In this *lovely* letter I will explain how objects work in C++: 165 | 166 | * Declare a class 167 | 168 | * Make an instance of it 169 | 170 | * Delete the instance 171 | 172 | ## More details 173 | 174 | Something like this: 175 | 176 | ``` 177 | class Foo {}; 178 | Foo f = Foo(); 179 | ``` 180 | 181 | And then use `new` and `delete` like this: 182 | 183 | ``` 184 | Foo* f = new Foo(); 185 | delete f; 186 | ``` 187 | 188 | Should work!" 189 | input = " 190 | Hi, dear **friend**! 191 | 192 | In this _lovely_ letter I will 193 | explain how objects 194 | work in C++: 195 | 196 | * \tDeclare a class 197 | * \tMake an instance of it 198 | * \tDelete the instance 199 | 200 | ## More details 201 | 202 | Something like this: 203 | 204 | ``` 205 | class Foo {}; 206 | Foo f = Foo(); 207 | ``` 208 | 209 | And then use `new` and `delete` like this: 210 | 211 | ```cpp 212 | Foo* f = new Foo(); 213 | delete f; 214 | ``` 215 | 216 | Should work! 217 | " 218 | assert_equal(expected, GptTranslate::Plain.new(input).to_s) 219 | end 220 | end 221 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Translator of Jekyll Pages via ChatGPT 2 | 3 | ![logo](logo.png) 4 | 5 | [![rake](https://github.com/yegor256/jekyll-chatgpt-translate/actions/workflows/rake.yml/badge.svg)](https://github.com/yegor256/jekyll-chatgpt-translate/actions/workflows/rake.yml) 6 | [![Gem Version](https://badge.fury.io/rb/jekyll-chatgpt-translate.svg)](https://badge.fury.io/rb/jekyll-chatgpt-translate) 7 | 8 | If you have a [Jekyll](https://jekyllrb.com/) static site, 9 | this plugin may help you automatically 10 | translate its pages to another language, through 11 | [ChatGPT](https://chat.openai.com/). See how it 12 | works for [my blog](https://github.com/yegor256/ru.yegor256.com), 13 | for example [this page](https://ru.yegor256.com/2023-08-13-dictators.html) 14 | is translated to 15 | [English](https://www.yegor256.com/en/2023/08/13/dictators.html). 16 | 17 | Install it first (you need 18 | [Ruby 3+](https://www.ruby-lang.org/en/news/2020/12/25/ruby-3-0-0-released/) 19 | and [Jekyll 3+](https://jekyllrb.com/)): 20 | 21 | ```bash 22 | gem install jekyll-chatgpt-translate 23 | ``` 24 | 25 | Then, add this to `_config.yml`: 26 | 27 | ```yaml 28 | plugins: 29 | - ... your other plugins here ... 30 | - jekyll-chatgpt-translate 31 | chatgpt-translate: 32 | model: gpt-3.5-turbo 33 | source: en 34 | layout: translated 35 | targets: 36 | - 37 | language: zh 38 | permalink: :year-:month-:day-:slug-chinese.html 39 | layout: chinese-translated 40 | - 41 | only: ru-post 42 | language: fr 43 | permalink: :year-:month-:day-:title-french.html 44 | ``` 45 | 46 | Here, the source language is English (`en`), the targets are 47 | Chinese (`zh`) and French (`fr`), 48 | where the layout for Chinese is `_layout/chinese-translated.html` and for 49 | French is `_layout/translated.html` (you must have these files). 50 | 51 | OpenAI API KEY must be set in the `OPENAI_API_KEY` environment variable, 52 | otherwise 53 | the plugin will not do any translation and won't generate translated pages. 54 | You can get your key 55 | [here][open-ai]. 56 | 57 | OpenAI API base URL can be customized by the `OPENAI_API_BASE` 58 | environment variable. 59 | If this variable is not set, the default value is `https://api.openai.com/`. 60 | 61 | Inside the original page you can use `{{ page.chatgpt-translate.urls[XX] }}` 62 | in order to render the URL 63 | of the translated page, where `XX` is the [ISO-639-1][iso-639] 64 | code of the target language. 65 | Inside the translated page you can use 66 | `{{ page.chatgpt-translate.original-url }}` in order 67 | to get the URL of the page that was translated. 68 | 69 | You can also use `{{ page.chatgpt-translate.model }}` 70 | inside both the original page and the translated one, 71 | to refer to the model of ChatGPT. 72 | The presence of `{{ page.chatgpt-translate }}` means that the 73 | page was translated or the translated HTML was downloaded 74 | and placed into the `_site` directory. 75 | 76 | ## Options 77 | 78 | Full list of options available to specify in `_config.yml`: 79 | 80 | * `api_key_file` (optional) — the file with OpenAI API key. 81 | If this option is not specified, 82 | it is expected to have the key in the `OPENAI_API_KEY` environment variable. 83 | 84 | * `api_key` (optional) — the OpenAI API key itself. This is a very bad idea to 85 | specify it right in the `_config.yml` file, but it's still possible. 86 | 87 | * `model` (optional) — specifies the model to use by ChatGPT, 88 | [examples are here](https://github.com/alexrudall/ruby-openai#models). 89 | 90 | * `source` (optional) — is the [ISO-639-1][iso-639] code of the source language. 91 | 92 | * `no_download` (optional) — if this attribute is present, the plugin won't try 93 | to find HTML versions of translated pages in the Internet and won't try to 94 | download them and place into the `_site` directory. Thus, your entire site 95 | will have to be re-translated on every build (might be very ineffective 96 | if the site is big!) 97 | 98 | * `min_chars` (optional) — minimum number of chars that must be present in 99 | a paragraph in order for it to be feasible to go to ChatGPT. The robot 100 | doesn't translate short paragraphs pretty enough. It's better to keep this 101 | number big enough, to avoid silly translations. The default is 128. 102 | 103 | * `window_length` (optional) — maximum number of words to be sent to 104 | OpenAI API in one 105 | request. The default is 2048. 106 | 107 | * `layout` (optional) — is name of the file in `_layouts` directory, 108 | without the extension. 109 | This layout will be specified for the pages generated by this plugin. 110 | The default value is `translated` (expecting you to have 111 | `_layouts/translated.html` file available). 112 | 113 | * `targets` (mandatory) — an array of target languages, each of 114 | which has the following attributes 115 | 116 | * `only` (optional) — 117 | it this is present, only the posts with the provided "layout" 118 | will be translated to this target 119 | 120 | * `language` (mandatory) — 121 | [ISO-639-1][iso-639] code of the target language 122 | 123 | * `source` (optional) — 124 | [ISO-639-1][iso-639] code of the source language (overwrites the 125 | value of the `source` defined above) 126 | 127 | * `permalink` (mandatory) — template to use for newly generated pages 128 | 129 | * `layout` (optional) — the name of the file in the `_layouts` directory 130 | 131 | * `threshold` (optional) — maximum number of pages to generate 132 | in one build cycle. 133 | The default value is 1024. It is recommended to use smaller number, in order 134 | to avoid too long builds. You can re-run the build again and missing pages 135 | will be generated. Thus, in a few builds the entire site will be translated. 136 | 137 | * `version` (optional) — the version that will be attached to each 138 | generated page, 139 | in order to avoid repetitive translations on one hand 140 | and enable re-translations 141 | when the `version` is changed on another hand. By default, the version of 142 | this plugin will be used, unless you set your own value. 143 | 144 | * `tmpdir` (optional) — the name of the directory where to keep temporary files, 145 | `_chatgpt-translate` is the default value. 146 | 147 | ## How to Contribute 148 | 149 | Make a fork and then test it locally like this: 150 | 151 | ```bash 152 | bundle update 153 | bundle exec rake 154 | ``` 155 | 156 | If it works, make changes, test again, and then submit a pull request. 157 | 158 | In order to run a single test, do this: 159 | 160 | ```bash 161 | bundle exec ruby test/test_generator.rb 162 | ``` 163 | 164 | [open-ai]: https://help.openai.com/en/articles/4936850-where-do-i-find-my-secret-api-key 165 | [iso-639]: https://en.wikipedia.org/wiki/List_of_ISO_639-1_codes 166 | -------------------------------------------------------------------------------- /lib/jekyll-chatgpt-translate/generator.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # SPDX-FileCopyrightText: Copyright (c) 2023-2025 Yegor Bugayenko 4 | # SPDX-License-Identifier: MIT 5 | 6 | require 'jekyll' 7 | require 'fileutils' 8 | require 'json' 9 | require_relative 'chatgpt' 10 | require_relative 'permalink' 11 | require_relative 'ping' 12 | require_relative 'plain' 13 | require_relative 'version' 14 | 15 | # The module we are in. 16 | module GptTranslate; end 17 | 18 | # Pages generator. 19 | # Author:: Yegor Bugayenko (yegor256@gmail.com) 20 | # Copyright:: Copyright (c) 2023-2025 Yegor Bugayenko 21 | # License:: MIT 22 | class GptTranslate::Generator < Jekyll::Generator 23 | safe true 24 | priority :lowest 25 | 26 | # Main plugin action, called by Jekyll-core 27 | def generate(site) 28 | if ARGV.include?('--offline') 29 | Jekyll.logger.info("jekyll-chatgpt-translate #{GptTranslate::VERSION} skipped, due to the --offline option") 30 | return 31 | end 32 | Jekyll.logger.info("jekyll-chatgpt-translate #{GptTranslate::VERSION} starting...") 33 | config ||= site.config['chatgpt-translate'] || {} 34 | home = config['tmpdir'] || '_chatgpt-translate' 35 | key = api_key(config) 36 | if key.nil? 37 | Jekyll.logger.info('jekyll-chatgpt-translate requires OPENAI_API_KEY environment variable') 38 | return 39 | end 40 | layout = config['layout'] || 'translated' 41 | version = config['version'] || GptTranslate::VERSION 42 | threshold = config['threshold'] || 1024 43 | min_chars = config['min_chars'] || 128 44 | start = Time.now 45 | translated = 0 46 | copied = 0 47 | model = config['model'] || 'gpt-3.5-turbo' 48 | marker = "Translated by ChatGPT #{model}#{version.empty? ? '' : "/#{version}"}" 49 | site.posts.docs.shuffle.each_with_index do |doc, pos| 50 | plain = GptTranslate::Plain.new(doc.content).to_s 51 | layout = doc['layout'] 52 | config['targets'].each do |target| 53 | pstart = Time.now 54 | link = GptTranslate::Permalink.new(doc, target['permalink']).to_path 55 | lang = target['language'] 56 | raise 'Language must be defined for each target' if target.nil? 57 | only = target['only'] 58 | if !only.nil? && layout != only 59 | Jekyll.logger.debug("Not translating #{link.inspect}, b/c 'only' set to '#{only}'") 60 | next 61 | end 62 | path = File.join(home, lang, doc.basename.gsub(/\.md$/, "-#{lang}.md")) 63 | FileUtils.mkdir_p(File.dirname(path)) 64 | File.write( 65 | path, 66 | [ 67 | '---', 68 | "layout: #{target['layout'] || layout}", 69 | "title: #{doc['title'].to_json}", 70 | "description: #{doc['description'].to_json}", 71 | "permalink: #{link.to_json}", 72 | 'chatgpt-translate:', 73 | " original-url: #{doc.url.to_json}", 74 | " language: #{lang.to_json}", 75 | " model: #{model.to_json}", 76 | '---' 77 | ].join("\n") 78 | ) 79 | html = config['no_download'].nil? ? GptTranslate::Ping.new(site, link).download : nil 80 | needed = false 81 | added = false 82 | if html.nil? 83 | Jekyll.logger.info("The page is absent, need to translate #{link.inspect}") 84 | needed = true 85 | else 86 | copied += 1 87 | site.static_files << DownloadedFile.new(site, link, html) 88 | added = true 89 | if version.empty? 90 | Jekyll.logger.info("Re-translation not required, since version is empty: #{link.inspect}") 91 | elsif html.include?(marker) 92 | Jekyll.logger.info("No need to translate, the page exists at \ 93 | #{link.inspect} (#{html.split.count} words)") 94 | else 95 | Jekyll.logger.info("Re-translation required for #{link.inspect}") 96 | needed = true 97 | end 98 | end 99 | if translated >= threshold 100 | Jekyll.logger.info("Page ##{pos} is ignored, we are over the threshold of #{threshold}: #{link}") 101 | elsif needed 102 | gpt = GptTranslate::ChatGPT.new( 103 | key, 104 | model, 105 | target['source'] || config['source'] || 'en', 106 | lang 107 | ) 108 | foreign = gpt.translate( 109 | plain, 110 | min: min_chars, 111 | window_length: (config['window_length'] || '2048').to_i 112 | ) 113 | File.write( 114 | path, 115 | [ 116 | '', 117 | foreign, 118 | '', 119 | "#{marker} on #{Time.now.strftime('%Y-%m-%d at %H:%M')}\n{: .jekyll-chatgpt-translate}" 120 | ].join("\n"), 121 | mode: 'a+' 122 | ) 123 | site.pages << Jekyll::Page.new(site, site.source, File.dirname(path), File.basename(path)) 124 | site.static_files.delete_if { |f| f.is_a?(DownloadedFile) && f.link == link } 125 | added = true 126 | translated += 1 127 | Jekyll.logger.info("Translated via ChatGPT \ 128 | in #{(Time.now - pstart).round(2)}s: #{path} (#{File.size(path)} bytes)") 129 | end 130 | next unless added 131 | doc.data['chatgpt-translate'] ||= {} 132 | doc.data['chatgpt-translate']['model'] ||= model 133 | doc.data['chatgpt-translate']['urls'] ||= {} 134 | doc.data['chatgpt-translate']['urls'][lang] = link 135 | end 136 | end 137 | Jekyll.logger.info("jekyll-chatgpt-translate #{GptTranslate::VERSION}: \ 138 | #{translated} pages translated and #{copied} pages copied in #{(Time.now - start).round(2)}s") 139 | end 140 | 141 | # The file we just downloaded. 142 | class DownloadedFile < Jekyll::StaticFile 143 | attr_reader :link 144 | 145 | def initialize(site, link, html) 146 | super(site, site.dest, '', link) 147 | @html = html 148 | @link = link 149 | end 150 | 151 | def write(_dest) 152 | FileUtils.mkdir_p(File.dirname(path)) 153 | File.write(path, @html) 154 | Jekyll.logger.info("Saved #{@html.split.count} words to #{path.inspect}") 155 | true 156 | end 157 | end 158 | 159 | private 160 | 161 | # Try to find the KEY, either in the environment, a file, etc. 162 | # If not found, return NIL. 163 | def api_key(config) 164 | file = config['api_key_file'] 165 | key = if file.nil? 166 | k = ENV.fetch('OPENAI_API_KEY', nil) 167 | Jekyll.logger.info('The key is found in the OPENAI_API_KEY env variable') unless k.nil? 168 | k 169 | elsif File.exist?(file) 170 | k = File.read(file).strip 171 | Jekyll.logger.info("The OpenAI API key taken from the file: #{file.inspect} (#{k.length} chars)") 172 | k 173 | else 174 | Jekyll.logger.info("The file with the OpenAI API key is not found: #{file.inspect}") 175 | nil 176 | end 177 | if key.nil? && config['api_key'] 178 | Jekyll.logger.info("The OpenAI API key is found in 'api_key' of _config.yml") 179 | key = config['api_key'] 180 | end 181 | if key.nil? && Jekyll.env == 'development' 182 | Jekyll.logger.info("OPENAI_API_KEY environment variable is not set, \ 183 | the `api_key_file` option is not specified in the _config.yml, and \ 184 | we are in development mode, that's why no actual translation will happen, \ 185 | but .md pages will be generated") 186 | key = '' 187 | end 188 | key 189 | end 190 | end 191 | -------------------------------------------------------------------------------- /features/cli.feature: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: Copyright (c) 2023-2025 Yegor Bugayenko 2 | # SPDX-License-Identifier: MIT 3 | Feature: Simple site building 4 | I want to be able to build a site 5 | 6 | Scenario: Simple site 7 | Given I have a "_config.yml" file with content: 8 | """ 9 | markdown: kramdown 10 | plugins: 11 | - jekyll-chatgpt-translate 12 | chatgpt-translate: 13 | api_key_file: the-file-is-absent.txt 14 | source: en 15 | layout: translated 16 | targets: 17 | - 18 | language: zh 19 | permalink: :year-:month-:day-:slug-chinese.html 20 | layout: chinese-translated 21 | - 22 | language: fr 23 | permalink: :year/:slug-french.html 24 | """ 25 | And I have a "_layouts/default.html" file with content: 26 | """ 27 | The Chinese: {{ page.chatgpt-translate.urls['zh'] }} 28 | The French: {{ page.chatgpt-translate.urls['fr'] }} 29 | {{ content }} 30 | """ 31 | And I have a "_layouts/chinese-translated.html" file with content: 32 | """ 33 | Chinese: {{ content }} 34 | The original: {{ page.chatgpt-translate.original-url }} 35 | """ 36 | And I have a "_layouts/translated.html" file with content: 37 | """ 38 | French: {{ content }} 39 | The original: {{ page.chatgpt-translate.original-url }} 40 | """ 41 | And I have a "_posts/2023-01-01-hello.md" file with content: 42 | """ 43 | --- 44 | title: Hello, world! 45 | layout: default 46 | --- 47 | Hello, world! 48 | """ 49 | Then I build Jekyll site 50 | And Exit code is zero 51 | And File "_chatgpt-translate/zh/2023-01-01-hello-zh.md" exists 52 | And File "_chatgpt-translate/zh/2023-01-01-hello-zh.md" contains "/2023-01-01-hello-chinese.html" 53 | And File "_chatgpt-translate/zh/2023-01-01-hello-zh.md" contains "language: \"zh\"" 54 | And File "_site/2023/01/01/hello.html" exists 55 | And File "_site/2023/01/01/hello.html" contains "The Chinese: /2023-01-01-hello-chinese.html" 56 | And File "_site/2023-01-01-hello-chinese.html" exists 57 | And File "_site/2023-01-01-hello-chinese.html" contains "The original: /2023/01/01/hello.html" 58 | And File "_site/2023/hello-french.html" exists 59 | 60 | Scenario: Simple download of existing page 61 | Given I have a "_config.yml" file with content: 62 | """ 63 | url: https://www.yegor256.com 64 | markdown: kramdown 65 | plugins: 66 | - jekyll-chatgpt-translate 67 | chatgpt-translate: 68 | source: en 69 | version: "" 70 | api_key: "it-is-not-used, because EN to EN translation" 71 | window_length: 1024 72 | layout: should-not-be-used 73 | targets: 74 | - 75 | language: ru 76 | permalink: about-me.html 77 | """ 78 | And I have a "_posts/2023-01-01-hello.md" file with content: 79 | """ 80 | --- 81 | title: foo 82 | --- 83 | see translated page: {{ page.chatgpt-translate.urls['ru'] }} 84 | """ 85 | Then I build Jekyll site 86 | And Exit code is zero 87 | And Stdout contains "Re-translation not required, since version is empty" 88 | And File "_site/2023/01/01/hello.html" exists 89 | And File "_site/2023/01/01/hello.html" contains "see translated page: /about-me.html" 90 | And File "_site/about-me.html" exists 91 | And File "_site/about-me.html" contains "Yegor Bugayenko" 92 | 93 | Scenario: Simple download of existing page, but with re-translation 94 | Given I have a "_config.yml" file with content: 95 | """ 96 | url: https://www.yegor256.com 97 | markdown: kramdown 98 | plugins: 99 | - jekyll-chatgpt-translate 100 | chatgpt-translate: 101 | source: en 102 | version: "my-own-version" 103 | api_key: "it-is-not-used, because EN to EN translation" 104 | layout: default 105 | targets: 106 | - 107 | language: en 108 | permalink: about-me.html 109 | """ 110 | And I have a "boom.html" file with content: 111 | """ 112 | Boom! 113 | """ 114 | And I have a "_layouts/default.html" file with content: 115 | """ 116 | {{ content }} 117 | """ 118 | And I have a "_posts/2023-01-01-hello.md" file with content: 119 | """ 120 | --- 121 | title: foo 122 | --- 123 | foo-file-foo 124 | """ 125 | Then I build Jekyll site 126 | And Exit code is zero 127 | And Stdout contains "Re-translation required for" 128 | And File "_site/2023/01/01/hello.html" exists 129 | And File "_site/about-me.html" exists 130 | And File "_site/about-me.html" contains "foo-file-foo" 131 | And File "_site/boom.html" exists 132 | 133 | Scenario: Simple translation with links to other pages 134 | Given I have a "_config.yml" file with content: 135 | """ 136 | url: https://www.yegor256.com 137 | markdown: kramdown 138 | plugins: 139 | - jekyll-chatgpt-translate 140 | chatgpt-translate: 141 | source: en 142 | api_key: "it-is-not-used, because EN to EN translation" 143 | layout: default 144 | targets: 145 | - 146 | language: en 147 | permalink: :slug.html 148 | """ 149 | And I have a "_layouts/default.html" file with content: 150 | """ 151 | {{ content }} 152 | """ 153 | And I have a "_posts/2023-01-01-hello.md" file with content: 154 | """ 155 | --- 156 | title: foo 157 | --- 158 | See {% post_url 2023-02-02-bye %} 159 | """ 160 | And I have a "_posts/2023-02-02-bye.md" file with content: 161 | """ 162 | --- 163 | title: foo 164 | --- 165 | See {% post_url 2023-01-01-hello %} 166 | """ 167 | Then I build Jekyll site 168 | And Exit code is zero 169 | And Stdout contains "The page is absent, need to translate" 170 | And File "_site/2023/01/01/hello.html" exists 171 | And File "_site/2023/01/01/hello.html" contains "/bye.html" 172 | And File "_site/2023/02/02/bye.html" exists 173 | And File "_site/2023/02/02/bye.html" contains "/hello.html" 174 | 175 | Scenario: No translation at all 176 | Given I have a "_config.yml" file with content: 177 | """ 178 | url: https://www.yegor256.com 179 | markdown: kramdown 180 | plugins: 181 | - jekyll-chatgpt-translate 182 | chatgpt-translate: 183 | source: en 184 | threshold: 0 185 | api_key: "it-is-not-used, because EN to EN translation" 186 | layout: default 187 | targets: 188 | - 189 | language: en 190 | permalink: :slug.html 191 | """ 192 | And I have a "_layouts/default.html" file with content: 193 | """ 194 | {{ content }} 195 | """ 196 | And I have a "_posts/2023-01-01-hello.md" file with content: 197 | """ 198 | --- 199 | title: foo 200 | --- 201 | {% if page.chatgpt-translate.model %} 202 | TRANSLATED :( 203 | {% else %} 204 | NO TRANSLATION! :) 205 | {% endif %} 206 | """ 207 | Then I build Jekyll site 208 | And Exit code is zero 209 | And Stdout contains "The page is absent, need to translate" 210 | And File "_site/2023/01/01/hello.html" exists 211 | And File "_site/2023/01/01/hello.html" contains "NO TRANSLATION!" 212 | 213 | Scenario: Translation skipped due to the ONLY tag 214 | Given I have a "_config.yml" file with content: 215 | """ 216 | url: https://www.yegor256.com 217 | markdown: kramdown 218 | plugins: 219 | - jekyll-chatgpt-translate 220 | chatgpt-translate: 221 | source: en 222 | threshold: 0 223 | api_key: "it-is-not-used, because EN to EN translation" 224 | layout: default 225 | targets: 226 | - 227 | only: ABC 228 | language: en 229 | permalink: :slug-en.html 230 | """ 231 | And I have a "_layouts/default.html" file with content: 232 | """ 233 | {{ content }} 234 | """ 235 | And I have a "_posts/2023-01-01-hello.md" file with content: 236 | """ 237 | --- 238 | layout: default 239 | title: foo 240 | --- 241 | Hello, world! 242 | """ 243 | Then I build Jekyll site 244 | And Exit code is zero 245 | And File "_site/2023/01/01/hello.html" exists 246 | And File "_site/2023/01/01/hello-en.html" doesn't exist 247 | --------------------------------------------------------------------------------