diff --git a/lib/rdoc/generator/template/aliki/_head.rhtml b/lib/rdoc/generator/template/aliki/_head.rhtml
index 1733bd0174..c46f0b94e7 100644
--- a/lib/rdoc/generator/template/aliki/_head.rhtml
+++ b/lib/rdoc/generator/template/aliki/_head.rhtml
@@ -140,6 +140,11 @@
defer
>
+
+
/g, '>')
+ .replace(/"/g, '"')
+ .replace(/'/g, ''');
+ }
+
+ function wrap(className, text) {
+ return '' + escapeHtml(text) + '';
+ }
+
+ function highlightLine(line) {
+ if (line.trim() === '') return escapeHtml(line);
+
+ var result = '';
+ var i = 0;
+ var len = line.length;
+
+ // Preserve leading whitespace
+ while (i < len && (line[i] === ' ' || line[i] === '\t')) {
+ result += escapeHtml(line[i++]);
+ }
+
+ // Check for $ prompt ($ followed by space or end of line)
+ if (line[i] === '$' && (line[i + 1] === ' ' || line[i + 1] === undefined)) {
+ result += wrap('sh-prompt', '$');
+ i++;
+ }
+
+ // Check for # comment at start
+ if (line[i] === '#') {
+ return result + wrap('sh-comment', line.slice(i));
+ }
+
+ var seenCommand = false;
+ var afterSpace = true;
+
+ while (i < len) {
+ var ch = line[i];
+
+ // Whitespace
+ if (ch === ' ' || ch === '\t') {
+ result += escapeHtml(ch);
+ i++;
+ afterSpace = true;
+ continue;
+ }
+
+ // Comment after whitespace
+ if (ch === '#' && afterSpace) {
+ result += wrap('sh-comment', line.slice(i));
+ break;
+ }
+
+ // Double-quoted string
+ if (ch === '"') {
+ var end = i + 1;
+ while (end < len && line[end] !== '"') {
+ if (line[end] === '\\' && end + 1 < len) end += 2;
+ else end++;
+ }
+ if (end < len) end++;
+ result += wrap('sh-string', line.slice(i, end));
+ i = end;
+ afterSpace = false;
+ continue;
+ }
+
+ // Single-quoted string
+ if (ch === "'") {
+ var end = i + 1;
+ while (end < len && line[end] !== "'") end++;
+ if (end < len) end++;
+ result += wrap('sh-string', line.slice(i, end));
+ i = end;
+ afterSpace = false;
+ continue;
+ }
+
+ // Environment variable (ALLCAPS=)
+ if (afterSpace && /[A-Z]/.test(ch)) {
+ var match = line.slice(i).match(/^[A-Z][A-Z0-9_]*=/);
+ if (match) {
+ result += wrap('sh-envvar', match[0]);
+ i += match[0].length;
+ // Read unquoted value
+ var valEnd = i;
+ while (valEnd < len && line[valEnd] !== ' ' && line[valEnd] !== '\t' && line[valEnd] !== '"' && line[valEnd] !== "'") valEnd++;
+ if (valEnd > i) {
+ result += escapeHtml(line.slice(i, valEnd));
+ i = valEnd;
+ }
+ afterSpace = false;
+ continue;
+ }
+ }
+
+ // Option (must be after whitespace)
+ if (ch === '-' && afterSpace) {
+ var match = line.slice(i).match(/^--?[a-zA-Z0-9_-]+(=[^\s]*)?/);
+ if (match) {
+ result += wrap('sh-option', match[0]);
+ i += match[0].length;
+ afterSpace = false;
+ continue;
+ }
+ }
+
+ // Command (first word: regular, ./path, ../path, ~/path, @scope/pkg)
+ if (!seenCommand && afterSpace) {
+ var isCmd = /[a-zA-Z0-9@~]/.test(ch) ||
+ (ch === '.' && (line[i + 1] === '/' || (line[i + 1] === '.' && line[i + 2] === '/')));
+ if (isCmd) {
+ var end = i;
+ while (end < len && line[end] !== ' ' && line[end] !== '\t') end++;
+ result += wrap('sh-command', line.slice(i, end));
+ i = end;
+ seenCommand = true;
+ afterSpace = false;
+ continue;
+ }
+ }
+
+ // Everything else
+ result += escapeHtml(ch);
+ i++;
+ afterSpace = false;
+ }
+
+ return result;
+ }
+
+ function highlightShell(code) {
+ return code.split('\n').map(highlightLine).join('\n');
+ }
+
+ function initHighlighting() {
+ var selectors = [
+ 'pre.bash', 'pre.sh', 'pre.shell', 'pre.console',
+ 'pre[data-language="bash"]', 'pre[data-language="sh"]',
+ 'pre[data-language="shell"]', 'pre[data-language="console"]'
+ ];
+
+ var blocks = document.querySelectorAll(selectors.join(', '));
+ blocks.forEach(function(block) {
+ if (block.getAttribute('data-highlighted') === 'true') return;
+ block.innerHTML = highlightShell(block.textContent);
+ block.setAttribute('data-highlighted', 'true');
+ });
+ }
+
+ if (document.readyState === 'loading') {
+ document.addEventListener('DOMContentLoaded', initHighlighting);
+ } else {
+ initHighlighting();
+ }
+})();
diff --git a/test/rdoc/generator/aliki/highlight_bash_test.rb b/test/rdoc/generator/aliki/highlight_bash_test.rb
new file mode 100644
index 0000000000..103dce350d
--- /dev/null
+++ b/test/rdoc/generator/aliki/highlight_bash_test.rb
@@ -0,0 +1,181 @@
+# frozen_string_literal: true
+
+require_relative '../../helper'
+
+return if RUBY_DESCRIPTION =~ /truffleruby/ || RUBY_DESCRIPTION =~ /jruby/
+
+begin
+ require 'mini_racer'
+rescue LoadError
+ return
+end
+
+class RDocGeneratorAlikiHighlightBashTest < Test::Unit::TestCase
+ HIGHLIGHT_BASH_JS_PATH = File.expand_path(
+ '../../../../lib/rdoc/generator/template/aliki/js/bash_highlighter.js',
+ __dir__
+ )
+
+ HIGHLIGHT_BASH_JS = begin
+ highlight_bash_js = File.read(HIGHLIGHT_BASH_JS_PATH)
+
+ # We need to modify the JS slightly to make it work in the context of a test.
+ highlight_bash_js.gsub(
+ /\(function\(\) \{[\s\S]*'use strict';/,
+ "// Test wrapper\n"
+ ).gsub(
+ /if \(document\.readyState[\s\S]*\}\)\(\);/,
+ "// Removed DOM initialization for testing"
+ )
+ end.freeze
+
+ def setup
+ @context = MiniRacer::Context.new
+ @context.eval(HIGHLIGHT_BASH_JS)
+ end
+
+ def teardown
+ @context.dispose
+ end
+
+ def test_prompts
+ # $ followed by space or end of line is a prompt
+ [
+ ['$ bundle exec rake', '$'],
+ [' $ npm install', '$'],
+ ['$', '$'],
+ ].each do |input, expected|
+ assert_includes highlight(input), expected, "Failed for: #{input}"
+ end
+
+ # $VAR is a variable, not a prompt
+ refute_includes highlight('$HOME/bin'), ''
+ end
+
+ def test_comments
+ [
+ ['# This is a comment', ''],
+ ['bundle exec rake # Run tests', ''],
+ ].each do |input, expected|
+ assert_includes highlight(input), expected, "Failed for: #{input}"
+ end
+ end
+
+ def test_options
+ [
+ ['ls -l', '-l'],
+ ['npm install --save-dev', '--save-dev'],
+ ['git commit --message=fix', '--message=fix'],
+ ['ls -la --color=auto', ['-la', '--color=auto']],
+ ].each do |input, expected|
+ Array(expected).each do |exp|
+ assert_includes highlight(input), exp, "Failed for: #{input}"
+ end
+ end
+ end
+
+ def test_strings
+ [
+ ['echo "hello world"', '"hello world"'],
+ ["echo 'hello world'", "'hello world'"],
+ ['echo "hello \"world\""', '"hello \"world\""'],
+ ['npx @herb-tools/linter "**/*.rhtml"', '"**/*.rhtml"'],
+ ].each do |input, expected|
+ assert_includes highlight(input), expected, "Failed for: #{input}"
+ end
+ end
+
+ def test_commands
+ result = highlight('bundle exec rake')
+ assert_includes result, 'bundle'
+ # Only the first word is highlighted as command
+ refute_includes result, 'exec'
+ refute_includes result, 'rake'
+ end
+
+ def test_path_commands
+ [
+ ['./configure --prefix=/usr/local', './configure'],
+ ['../configure --enable-gcov', '../configure'],
+ ['./autogen.sh', './autogen.sh'],
+ ['~/.rubies/ruby-master/bin/ruby -e "puts 1"', '~/.rubies/ruby-master/bin/ruby'],
+ ].each do |input, expected|
+ assert_includes highlight(input), expected, "Failed for: #{input}"
+ end
+ end
+
+ def test_environment_variables
+ [
+ ['COVERAGE=true make test', ['COVERAGE=', 'make']],
+ ['CC=clang CXX=clang++ make', ['CC=', 'CXX=', 'make']],
+ ['RUBY_TEST_TIMEOUT_SCALE=5 make check', ['RUBY_TEST_TIMEOUT_SCALE=', 'make']],
+ ].each do |input, expected|
+ Array(expected).each do |exp|
+ assert_includes highlight(input), exp, "Failed for: #{input}"
+ end
+ end
+ end
+
+ def test_hyphens_in_words_not_options
+ # Hyphen in @herb-tools/linter should NOT be treated as option
+ result = highlight('npx @herb-tools/linter')
+ assert_includes result, 'npx'
+ refute_includes result, '-tools/linter'
+ assert_includes result, '@herb-tools/linter'
+
+ # Command with hyphen gets highlighted as command, not option
+ result = highlight('some-command arg')
+ assert_includes result, 'some-command'
+ refute_includes result, ''
+ end
+
+ def test_complex_commands
+ # Typical shell command with prompt
+ result = highlight('$ bundle exec rubocop -A')
+ assert_includes result, '$'
+ assert_includes result, 'bundle'
+ assert_includes result, '-A'
+
+ # Complex git command
+ result = highlight('$ git commit -m "Fix bug" --no-verify')
+ assert_includes result, '$'
+ assert_includes result, 'git'
+ assert_includes result, '-m'
+ assert_includes result, '"Fix bug"'
+ assert_includes result, '--no-verify'
+ end
+
+ def test_multiline_with_comments
+ code = <<~SHELL
+ # Generate documentation (creates _site directory)
+ bundle exec rake rdoc
+
+ # Force regenerate documentation
+ bundle exec rake rerdoc
+ SHELL
+
+ result = highlight(code)
+ assert_includes result, ''
+ assert_includes result, ''
+ end
+
+ def test_empty_and_whitespace
+ assert_equal '', highlight('')
+ assert_equal " \n\t \n ", highlight(" \n\t \n ")
+ end
+
+ def test_html_escaping
+ result = highlight('echo ""')
+ assert_includes result, '<script>'
+ assert_includes result, '</script>'
+
+ result = highlight('echo "a && b"')
+ assert_includes result, '&&'
+ end
+
+ private
+
+ def highlight(code)
+ @context.eval("highlightShell(#{code.to_json})")
+ end
+end