├── 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 | ""
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('', chat.translate('', 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('', GptTranslate::Plain.new('').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