Skip to content

Commit d920014

Browse files
committed
Add client-side shell syntax highlighter for Aliki theme
Add a simple shell/bash syntax highlighter for documentation code blocks. The highlighter is focused on command-line documentation rather than full bash scripts. Highlights: - $ prompts (gray) - Commands/executables - first word on line (blue) - Options like -f, --flag, --option=value (cyan) - Single and double quoted strings (green) - Comments starting with # (gray, italic) The highlighter targets code blocks with language classes: bash, sh, shell, console. Files added: - lib/rdoc/generator/template/aliki/js/bash_highlighter.js - test/rdoc/generator/aliki/highlight_bash_test.rb
1 parent dce3cf8 commit d920014

File tree

4 files changed

+365
-0
lines changed

4 files changed

+365
-0
lines changed

lib/rdoc/generator/template/aliki/_head.rhtml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,11 @@
140140
defer
141141
></script>
142142

143+
<script
144+
src="<%= h asset_rel_prefix %>/js/bash_highlighter.js?v=<%= h RDoc::VERSION %>"
145+
defer
146+
></script>
147+
143148
<script
144149
src="<%= h asset_rel_prefix %>/js/aliki.js?v=<%= h RDoc::VERSION %>"
145150
defer

lib/rdoc/generator/template/aliki/css/rdoc.css

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1059,6 +1059,18 @@ main h6 a:hover {
10591059
font-style: italic;
10601060
}
10611061

1062+
/* Shell Syntax Highlighting */
1063+
.sh-prompt { color: var(--code-gray); }
1064+
.sh-command { color: var(--code-blue); }
1065+
.sh-option { color: var(--code-cyan); }
1066+
.sh-string { color: var(--code-green); }
1067+
.sh-envvar { color: var(--code-purple); }
1068+
1069+
.sh-comment {
1070+
color: var(--code-gray);
1071+
font-style: italic;
1072+
}
1073+
10621074
/* Emphasis */
10631075
em {
10641076
text-decoration-color: var(--color-emphasis-decoration);
Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
/**
2+
* Client-side shell syntax highlighter for RDoc
3+
* Highlights: $ prompts, commands, options, strings, env vars, comments
4+
*/
5+
6+
(function() {
7+
'use strict';
8+
9+
function escapeHtml(text) {
10+
return text
11+
.replace(/&/g, '&amp;')
12+
.replace(/</g, '&lt;')
13+
.replace(/>/g, '&gt;')
14+
.replace(/"/g, '&quot;')
15+
.replace(/'/g, '&#39;');
16+
}
17+
18+
function wrap(className, text) {
19+
return '<span class="' + className + '">' + escapeHtml(text) + '</span>';
20+
}
21+
22+
function highlightLine(line) {
23+
if (line.trim() === '') return escapeHtml(line);
24+
25+
var result = '';
26+
var i = 0;
27+
var len = line.length;
28+
29+
// Preserve leading whitespace
30+
while (i < len && (line[i] === ' ' || line[i] === '\t')) {
31+
result += escapeHtml(line[i++]);
32+
}
33+
34+
// Check for $ prompt ($ followed by space or end of line)
35+
if (line[i] === '$' && (line[i + 1] === ' ' || line[i + 1] === undefined)) {
36+
result += wrap('sh-prompt', '$');
37+
i++;
38+
}
39+
40+
// Check for # comment at start
41+
if (line[i] === '#') {
42+
return result + wrap('sh-comment', line.slice(i));
43+
}
44+
45+
var seenCommand = false;
46+
var afterSpace = true;
47+
48+
while (i < len) {
49+
var ch = line[i];
50+
51+
// Whitespace
52+
if (ch === ' ' || ch === '\t') {
53+
result += escapeHtml(ch);
54+
i++;
55+
afterSpace = true;
56+
continue;
57+
}
58+
59+
// Comment after whitespace
60+
if (ch === '#' && afterSpace) {
61+
result += wrap('sh-comment', line.slice(i));
62+
break;
63+
}
64+
65+
// Double-quoted string
66+
if (ch === '"') {
67+
var end = i + 1;
68+
while (end < len && line[end] !== '"') {
69+
if (line[end] === '\\' && end + 1 < len) end += 2;
70+
else end++;
71+
}
72+
if (end < len) end++;
73+
result += wrap('sh-string', line.slice(i, end));
74+
i = end;
75+
afterSpace = false;
76+
continue;
77+
}
78+
79+
// Single-quoted string
80+
if (ch === "'") {
81+
var end = i + 1;
82+
while (end < len && line[end] !== "'") end++;
83+
if (end < len) end++;
84+
result += wrap('sh-string', line.slice(i, end));
85+
i = end;
86+
afterSpace = false;
87+
continue;
88+
}
89+
90+
// Environment variable (ALLCAPS=)
91+
if (afterSpace && /[A-Z]/.test(ch)) {
92+
var match = line.slice(i).match(/^[A-Z][A-Z0-9_]*=/);
93+
if (match) {
94+
result += wrap('sh-envvar', match[0]);
95+
i += match[0].length;
96+
// Read unquoted value
97+
var valEnd = i;
98+
while (valEnd < len && line[valEnd] !== ' ' && line[valEnd] !== '\t' && line[valEnd] !== '"' && line[valEnd] !== "'") valEnd++;
99+
if (valEnd > i) {
100+
result += escapeHtml(line.slice(i, valEnd));
101+
i = valEnd;
102+
}
103+
afterSpace = false;
104+
continue;
105+
}
106+
}
107+
108+
// Option (must be after whitespace)
109+
if (ch === '-' && afterSpace) {
110+
var match = line.slice(i).match(/^--?[a-zA-Z0-9_-]+(=[^\s]*)?/);
111+
if (match) {
112+
result += wrap('sh-option', match[0]);
113+
i += match[0].length;
114+
afterSpace = false;
115+
continue;
116+
}
117+
}
118+
119+
// Command (first word: regular, ./path, ../path, ~/path, @scope/pkg)
120+
if (!seenCommand && afterSpace) {
121+
var isCmd = /[a-zA-Z0-9@~]/.test(ch) ||
122+
(ch === '.' && (line[i + 1] === '/' || (line[i + 1] === '.' && line[i + 2] === '/')));
123+
if (isCmd) {
124+
var end = i;
125+
while (end < len && line[end] !== ' ' && line[end] !== '\t') end++;
126+
result += wrap('sh-command', line.slice(i, end));
127+
i = end;
128+
seenCommand = true;
129+
afterSpace = false;
130+
continue;
131+
}
132+
}
133+
134+
// Everything else
135+
result += escapeHtml(ch);
136+
i++;
137+
afterSpace = false;
138+
}
139+
140+
return result;
141+
}
142+
143+
function highlightShell(code) {
144+
return code.split('\n').map(highlightLine).join('\n');
145+
}
146+
147+
function initHighlighting() {
148+
var selectors = [
149+
'pre.bash', 'pre.sh', 'pre.shell', 'pre.console',
150+
'pre[data-language="bash"]', 'pre[data-language="sh"]',
151+
'pre[data-language="shell"]', 'pre[data-language="console"]'
152+
];
153+
154+
var blocks = document.querySelectorAll(selectors.join(', '));
155+
blocks.forEach(function(block) {
156+
if (block.getAttribute('data-highlighted') === 'true') return;
157+
block.innerHTML = highlightShell(block.textContent);
158+
block.setAttribute('data-highlighted', 'true');
159+
});
160+
}
161+
162+
if (document.readyState === 'loading') {
163+
document.addEventListener('DOMContentLoaded', initHighlighting);
164+
} else {
165+
initHighlighting();
166+
}
167+
})();
Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,181 @@
1+
# frozen_string_literal: true
2+
3+
require_relative '../../helper'
4+
5+
return if RUBY_DESCRIPTION =~ /truffleruby/ || RUBY_DESCRIPTION =~ /jruby/
6+
7+
begin
8+
require 'mini_racer'
9+
rescue LoadError
10+
return
11+
end
12+
13+
class RDocGeneratorAlikiHighlightBashTest < Test::Unit::TestCase
14+
HIGHLIGHT_BASH_JS_PATH = File.expand_path(
15+
'../../../../lib/rdoc/generator/template/aliki/js/bash_highlighter.js',
16+
__dir__
17+
)
18+
19+
HIGHLIGHT_BASH_JS = begin
20+
highlight_bash_js = File.read(HIGHLIGHT_BASH_JS_PATH)
21+
22+
# We need to modify the JS slightly to make it work in the context of a test.
23+
highlight_bash_js.gsub(
24+
/\(function\(\) \{[\s\S]*'use strict';/,
25+
"// Test wrapper\n"
26+
).gsub(
27+
/if \(document\.readyState[\s\S]*\}\)\(\);/,
28+
"// Removed DOM initialization for testing"
29+
)
30+
end.freeze
31+
32+
def setup
33+
@context = MiniRacer::Context.new
34+
@context.eval(HIGHLIGHT_BASH_JS)
35+
end
36+
37+
def teardown
38+
@context.dispose
39+
end
40+
41+
def test_prompts
42+
# $ followed by space or end of line is a prompt
43+
[
44+
['$ bundle exec rake', '<span class="sh-prompt">$</span>'],
45+
[' $ npm install', '<span class="sh-prompt">$</span>'],
46+
['$', '<span class="sh-prompt">$</span>'],
47+
].each do |input, expected|
48+
assert_includes highlight(input), expected, "Failed for: #{input}"
49+
end
50+
51+
# $VAR is a variable, not a prompt
52+
refute_includes highlight('$HOME/bin'), '<span class="sh-prompt">'
53+
end
54+
55+
def test_comments
56+
[
57+
['# This is a comment', '<span class="sh-comment"># This is a comment</span>'],
58+
['bundle exec rake # Run tests', '<span class="sh-comment"># Run tests</span>'],
59+
].each do |input, expected|
60+
assert_includes highlight(input), expected, "Failed for: #{input}"
61+
end
62+
end
63+
64+
def test_options
65+
[
66+
['ls -l', '<span class="sh-option">-l</span>'],
67+
['npm install --save-dev', '<span class="sh-option">--save-dev</span>'],
68+
['git commit --message=fix', '<span class="sh-option">--message=fix</span>'],
69+
['ls -la --color=auto', ['<span class="sh-option">-la</span>', '<span class="sh-option">--color=auto</span>']],
70+
].each do |input, expected|
71+
Array(expected).each do |exp|
72+
assert_includes highlight(input), exp, "Failed for: #{input}"
73+
end
74+
end
75+
end
76+
77+
def test_strings
78+
[
79+
['echo "hello world"', '<span class="sh-string">&quot;hello world&quot;</span>'],
80+
["echo 'hello world'", "<span class=\"sh-string\">&#39;hello world&#39;</span>"],
81+
['echo "hello \"world\""', '<span class="sh-string">&quot;hello \&quot;world\&quot;&quot;</span>'],
82+
['npx @herb-tools/linter "**/*.rhtml"', '<span class="sh-string">&quot;**/*.rhtml&quot;</span>'],
83+
].each do |input, expected|
84+
assert_includes highlight(input), expected, "Failed for: #{input}"
85+
end
86+
end
87+
88+
def test_commands
89+
result = highlight('bundle exec rake')
90+
assert_includes result, '<span class="sh-command">bundle</span>'
91+
# Only the first word is highlighted as command
92+
refute_includes result, '<span class="sh-command">exec</span>'
93+
refute_includes result, '<span class="sh-command">rake</span>'
94+
end
95+
96+
def test_path_commands
97+
[
98+
['./configure --prefix=/usr/local', '<span class="sh-command">./configure</span>'],
99+
['../configure --enable-gcov', '<span class="sh-command">../configure</span>'],
100+
['./autogen.sh', '<span class="sh-command">./autogen.sh</span>'],
101+
['~/.rubies/ruby-master/bin/ruby -e "puts 1"', '<span class="sh-command">~/.rubies/ruby-master/bin/ruby</span>'],
102+
].each do |input, expected|
103+
assert_includes highlight(input), expected, "Failed for: #{input}"
104+
end
105+
end
106+
107+
def test_environment_variables
108+
[
109+
['COVERAGE=true make test', ['<span class="sh-envvar">COVERAGE=</span>', '<span class="sh-command">make</span>']],
110+
['CC=clang CXX=clang++ make', ['<span class="sh-envvar">CC=</span>', '<span class="sh-envvar">CXX=</span>', '<span class="sh-command">make</span>']],
111+
['RUBY_TEST_TIMEOUT_SCALE=5 make check', ['<span class="sh-envvar">RUBY_TEST_TIMEOUT_SCALE=</span>', '<span class="sh-command">make</span>']],
112+
].each do |input, expected|
113+
Array(expected).each do |exp|
114+
assert_includes highlight(input), exp, "Failed for: #{input}"
115+
end
116+
end
117+
end
118+
119+
def test_hyphens_in_words_not_options
120+
# Hyphen in @herb-tools/linter should NOT be treated as option
121+
result = highlight('npx @herb-tools/linter')
122+
assert_includes result, '<span class="sh-command">npx</span>'
123+
refute_includes result, '<span class="sh-option">-tools/linter</span>'
124+
assert_includes result, '@herb-tools/linter'
125+
126+
# Command with hyphen gets highlighted as command, not option
127+
result = highlight('some-command arg')
128+
assert_includes result, '<span class="sh-command">some-command</span>'
129+
refute_includes result, '<span class="sh-option">'
130+
end
131+
132+
def test_complex_commands
133+
# Typical shell command with prompt
134+
result = highlight('$ bundle exec rubocop -A')
135+
assert_includes result, '<span class="sh-prompt">$</span>'
136+
assert_includes result, '<span class="sh-command">bundle</span>'
137+
assert_includes result, '<span class="sh-option">-A</span>'
138+
139+
# Complex git command
140+
result = highlight('$ git commit -m "Fix bug" --no-verify')
141+
assert_includes result, '<span class="sh-prompt">$</span>'
142+
assert_includes result, '<span class="sh-command">git</span>'
143+
assert_includes result, '<span class="sh-option">-m</span>'
144+
assert_includes result, '<span class="sh-string">&quot;Fix bug&quot;</span>'
145+
assert_includes result, '<span class="sh-option">--no-verify</span>'
146+
end
147+
148+
def test_multiline_with_comments
149+
code = <<~SHELL
150+
# Generate documentation (creates _site directory)
151+
bundle exec rake rdoc
152+
153+
# Force regenerate documentation
154+
bundle exec rake rerdoc
155+
SHELL
156+
157+
result = highlight(code)
158+
assert_includes result, '<span class="sh-comment"># Generate documentation (creates _site directory)</span>'
159+
assert_includes result, '<span class="sh-comment"># Force regenerate documentation</span>'
160+
end
161+
162+
def test_empty_and_whitespace
163+
assert_equal '', highlight('')
164+
assert_equal " \n\t \n ", highlight(" \n\t \n ")
165+
end
166+
167+
def test_html_escaping
168+
result = highlight('echo "<script>alert(1)</script>"')
169+
assert_includes result, '&lt;script&gt;'
170+
assert_includes result, '&lt;/script&gt;'
171+
172+
result = highlight('echo "a && b"')
173+
assert_includes result, '&amp;&amp;'
174+
end
175+
176+
private
177+
178+
def highlight(code)
179+
@context.eval("highlightShell(#{code.to_json})")
180+
end
181+
end

0 commit comments

Comments
 (0)