Skip to content

Commit a95dee6

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 a95dee6

File tree

4 files changed

+548
-0
lines changed

4 files changed

+548
-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: 243 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,243 @@
1+
/**
2+
* Client-side shell syntax highlighter for RDoc
3+
* Focused on command-line documentation (not full bash scripts)
4+
*
5+
* Highlights: $ prompts, commands (first word), options (--flag), strings, env vars (VAR=), comments (#)
6+
*/
7+
8+
(function() {
9+
'use strict';
10+
11+
/**
12+
* Escape HTML special characters
13+
*/
14+
function escapeHtml(text) {
15+
return text
16+
.replace(/&/g, '&amp;')
17+
.replace(/</g, '&lt;')
18+
.replace(/>/g, '&gt;')
19+
.replace(/"/g, '&quot;')
20+
.replace(/'/g, '&#39;');
21+
}
22+
23+
/**
24+
* Highlight a single line of shell code
25+
*/
26+
function highlightLine(line) {
27+
if (line.trim() === '') {
28+
return escapeHtml(line);
29+
}
30+
31+
const tokens = [];
32+
let i = 0;
33+
const len = line.length;
34+
35+
// Skip leading whitespace
36+
while (i < len && (line[i] === ' ' || line[i] === '\t')) {
37+
tokens.push(escapeHtml(line[i]));
38+
i++;
39+
}
40+
41+
// Check for $ prompt at line start (after whitespace)
42+
if (i < len && line[i] === '$') {
43+
const nextChar = line[i + 1];
44+
// $ followed by space or end of line = prompt
45+
if (nextChar === ' ' || nextChar === undefined || i + 1 >= len) {
46+
tokens.push('<span class="sh-prompt">', escapeHtml('$'), '</span>');
47+
i++;
48+
}
49+
}
50+
51+
// Check for # comment at line start (after whitespace)
52+
if (i < len && line[i] === '#') {
53+
// Entire rest of line is a comment
54+
tokens.push('<span class="sh-comment">', escapeHtml(line.substring(i)), '</span>');
55+
return tokens.join('');
56+
}
57+
58+
// Track if we're at a word boundary (after whitespace)
59+
let afterWhitespace = true;
60+
// Track if we've seen the command (first word) on this line
61+
let seenCommand = false;
62+
63+
// Process rest of line
64+
while (i < len) {
65+
const char = line[i];
66+
67+
// Whitespace
68+
if (char === ' ' || char === '\t') {
69+
tokens.push(escapeHtml(char));
70+
i++;
71+
afterWhitespace = true;
72+
continue;
73+
}
74+
75+
// Comment (# in middle of line, must be after whitespace)
76+
if (char === '#' && afterWhitespace) {
77+
tokens.push('<span class="sh-comment">', escapeHtml(line.substring(i)), '</span>');
78+
break;
79+
}
80+
81+
// Double-quoted string
82+
if (char === '"') {
83+
let end = i + 1;
84+
while (end < len && line[end] !== '"') {
85+
if (line[end] === '\\' && end + 1 < len) {
86+
end += 2;
87+
} else {
88+
end++;
89+
}
90+
}
91+
if (end < len) end++; // Include closing quote
92+
const str = line.substring(i, end);
93+
tokens.push('<span class="sh-string">', escapeHtml(str), '</span>');
94+
i = end;
95+
afterWhitespace = false;
96+
continue;
97+
}
98+
99+
// Single-quoted string
100+
if (char === "'") {
101+
let end = i + 1;
102+
while (end < len && line[end] !== "'") {
103+
end++;
104+
}
105+
if (end < len) end++; // Include closing quote
106+
const str = line.substring(i, end);
107+
tokens.push('<span class="sh-string">', escapeHtml(str), '</span>');
108+
i = end;
109+
afterWhitespace = false;
110+
continue;
111+
}
112+
113+
// Environment variable (ALLCAPS=value, must be after whitespace)
114+
if (afterWhitespace && /[A-Z]/.test(char)) {
115+
// Look ahead to see if this is an env var pattern
116+
let end = i + 1;
117+
while (end < len && /[A-Z0-9_]/.test(line[end])) {
118+
end++;
119+
}
120+
if (end < len && line[end] === '=') {
121+
// It's an environment variable
122+
const envName = line.substring(i, end + 1); // Include =
123+
tokens.push('<span class="sh-envvar">', escapeHtml(envName), '</span>');
124+
i = end + 1;
125+
// Read value (until space, unless quoted)
126+
if (i < len && (line[i] === '"' || line[i] === "'")) {
127+
// Value is quoted, will be handled by string parsing
128+
} else {
129+
// Unquoted value
130+
let valueEnd = i;
131+
while (valueEnd < len && line[valueEnd] !== ' ' && line[valueEnd] !== '\t') {
132+
valueEnd++;
133+
}
134+
if (valueEnd > i) {
135+
const value = line.substring(i, valueEnd);
136+
tokens.push(escapeHtml(value));
137+
i = valueEnd;
138+
}
139+
}
140+
afterWhitespace = false;
141+
continue;
142+
}
143+
// Not an env var, fall through to command/word handling
144+
}
145+
146+
// Option (starts with -, must be after whitespace)
147+
if (char === '-' && afterWhitespace) {
148+
let end = i + 1;
149+
// Handle -- long options
150+
if (end < len && line[end] === '-') {
151+
end++;
152+
}
153+
// Read option name (letters, numbers, dashes, underscores)
154+
while (end < len && /[a-zA-Z0-9_-]/.test(line[end])) {
155+
end++;
156+
}
157+
// Handle --option=value
158+
if (end < len && line[end] === '=') {
159+
end++;
160+
// Read value (until space or end)
161+
while (end < len && line[end] !== ' ' && line[end] !== '\t') {
162+
end++;
163+
}
164+
}
165+
const option = line.substring(i, end);
166+
tokens.push('<span class="sh-option">', escapeHtml(option), '</span>');
167+
i = end;
168+
afterWhitespace = false;
169+
continue;
170+
}
171+
172+
// Command (first word on line, starts with letter/number/@/~/. for paths)
173+
// Handles: command, ./script, ../script, ~/bin/cmd
174+
const isPathStart = char === '.' && i + 1 < len && (line[i + 1] === '/' || (line[i + 1] === '.' && i + 2 < len && line[i + 2] === '/'));
175+
if (!seenCommand && afterWhitespace && (/[a-zA-Z0-9@~]/.test(char) || isPathStart)) {
176+
let end = i + 1;
177+
// Read until whitespace or end
178+
while (end < len && line[end] !== ' ' && line[end] !== '\t') {
179+
end++;
180+
}
181+
const cmd = line.substring(i, end);
182+
tokens.push('<span class="sh-command">', escapeHtml(cmd), '</span>');
183+
i = end;
184+
afterWhitespace = false;
185+
seenCommand = true;
186+
continue;
187+
}
188+
189+
// Everything else (arguments, operators, punctuation)
190+
tokens.push(escapeHtml(char));
191+
i++;
192+
afterWhitespace = false;
193+
}
194+
195+
return tokens.join('');
196+
}
197+
198+
/**
199+
* Highlight shell source code
200+
*/
201+
function highlightShell(code) {
202+
const lines = code.split('\n');
203+
const highlighted = lines.map(highlightLine);
204+
return highlighted.join('\n');
205+
}
206+
207+
/**
208+
* Initialize shell syntax highlighting on page load
209+
*/
210+
function initHighlighting() {
211+
// Target code blocks with shell-related language classes
212+
const selectors = [
213+
'pre.bash',
214+
'pre.sh',
215+
'pre.shell',
216+
'pre.console',
217+
'pre[data-language="bash"]',
218+
'pre[data-language="sh"]',
219+
'pre[data-language="shell"]',
220+
'pre[data-language="console"]'
221+
];
222+
223+
const codeBlocks = document.querySelectorAll(selectors.join(', '));
224+
225+
codeBlocks.forEach(block => {
226+
if (block.getAttribute('data-highlighted') === 'true') {
227+
return;
228+
}
229+
230+
const code = block.textContent;
231+
const highlighted = highlightShell(code);
232+
233+
block.innerHTML = highlighted;
234+
block.setAttribute('data-highlighted', 'true');
235+
});
236+
}
237+
238+
if (document.readyState === 'loading') {
239+
document.addEventListener('DOMContentLoaded', initHighlighting);
240+
} else {
241+
initHighlighting();
242+
}
243+
})();

0 commit comments

Comments
 (0)