|
1 | 1 | /** |
2 | 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 (#) |
| 3 | + * Highlights: $ prompts, commands, options, strings, env vars, comments |
6 | 4 | */ |
7 | 5 |
|
8 | 6 | (function() { |
9 | 7 | 'use strict'; |
10 | 8 |
|
11 | | - /** |
12 | | - * Escape HTML special characters |
13 | | - */ |
14 | 9 | function escapeHtml(text) { |
15 | 10 | return text |
16 | 11 | .replace(/&/g, '&') |
|
20 | 15 | .replace(/'/g, '''); |
21 | 16 | } |
22 | 17 |
|
23 | | - /** |
24 | | - * Highlight a single line of shell code |
25 | | - */ |
| 18 | + function wrap(className, text) { |
| 19 | + return '<span class="' + className + '">' + escapeHtml(text) + '</span>'; |
| 20 | + } |
| 21 | + |
26 | 22 | function highlightLine(line) { |
27 | | - if (line.trim() === '') { |
28 | | - return escapeHtml(line); |
29 | | - } |
| 23 | + if (line.trim() === '') return escapeHtml(line); |
30 | 24 |
|
31 | | - const tokens = []; |
32 | | - let i = 0; |
33 | | - const len = line.length; |
| 25 | + var result = ''; |
| 26 | + var i = 0; |
| 27 | + var len = line.length; |
34 | 28 |
|
35 | | - // Skip leading whitespace |
| 29 | + // Preserve leading whitespace |
36 | 30 | while (i < len && (line[i] === ' ' || line[i] === '\t')) { |
37 | | - tokens.push(escapeHtml(line[i])); |
38 | | - i++; |
| 31 | + result += escapeHtml(line[i++]); |
39 | 32 | } |
40 | 33 |
|
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 | | - } |
| 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++; |
49 | 38 | } |
50 | 39 |
|
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(''); |
| 40 | + // Check for # comment at start |
| 41 | + if (line[i] === '#') { |
| 42 | + return result + wrap('sh-comment', line.slice(i)); |
56 | 43 | } |
57 | 44 |
|
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; |
| 45 | + var seenCommand = false; |
| 46 | + var afterSpace = true; |
62 | 47 |
|
63 | | - // Process rest of line |
64 | 48 | while (i < len) { |
65 | | - const char = line[i]; |
| 49 | + var ch = line[i]; |
66 | 50 |
|
67 | 51 | // Whitespace |
68 | | - if (char === ' ' || char === '\t') { |
69 | | - tokens.push(escapeHtml(char)); |
| 52 | + if (ch === ' ' || ch === '\t') { |
| 53 | + result += escapeHtml(ch); |
70 | 54 | i++; |
71 | | - afterWhitespace = true; |
| 55 | + afterSpace = true; |
72 | 56 | continue; |
73 | 57 | } |
74 | 58 |
|
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>'); |
| 59 | + // Comment after whitespace |
| 60 | + if (ch === '#' && afterSpace) { |
| 61 | + result += wrap('sh-comment', line.slice(i)); |
78 | 62 | break; |
79 | 63 | } |
80 | 64 |
|
81 | 65 | // Double-quoted string |
82 | | - if (char === '"') { |
83 | | - let end = i + 1; |
| 66 | + if (ch === '"') { |
| 67 | + var end = i + 1; |
84 | 68 | while (end < len && line[end] !== '"') { |
85 | | - if (line[end] === '\\' && end + 1 < len) { |
86 | | - end += 2; |
87 | | - } else { |
88 | | - end++; |
89 | | - } |
| 69 | + if (line[end] === '\\' && end + 1 < len) end += 2; |
| 70 | + else end++; |
90 | 71 | } |
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>'); |
| 72 | + if (end < len) end++; |
| 73 | + result += wrap('sh-string', line.slice(i, end)); |
94 | 74 | i = end; |
95 | | - afterWhitespace = false; |
| 75 | + afterSpace = false; |
96 | 76 | continue; |
97 | 77 | } |
98 | 78 |
|
99 | 79 | // 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>'); |
| 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)); |
108 | 85 | i = end; |
109 | | - afterWhitespace = false; |
| 86 | + afterSpace = false; |
110 | 87 | continue; |
111 | 88 | } |
112 | 89 |
|
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 | | - } |
| 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; |
139 | 102 | } |
140 | | - afterWhitespace = false; |
| 103 | + afterSpace = false; |
141 | 104 | continue; |
142 | 105 | } |
143 | | - // Not an env var, fall through to command/word handling |
144 | 106 | } |
145 | 107 |
|
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 | | - } |
| 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; |
164 | 116 | } |
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 | 117 | } |
171 | 118 |
|
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++; |
| 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; |
180 | 131 | } |
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 | 132 | } |
188 | 133 |
|
189 | | - // Everything else (arguments, operators, punctuation) |
190 | | - tokens.push(escapeHtml(char)); |
| 134 | + // Everything else |
| 135 | + result += escapeHtml(ch); |
191 | 136 | i++; |
192 | | - afterWhitespace = false; |
| 137 | + afterSpace = false; |
193 | 138 | } |
194 | 139 |
|
195 | | - return tokens.join(''); |
| 140 | + return result; |
196 | 141 | } |
197 | 142 |
|
198 | | - /** |
199 | | - * Highlight shell source code |
200 | | - */ |
201 | 143 | function highlightShell(code) { |
202 | | - const lines = code.split('\n'); |
203 | | - const highlighted = lines.map(highlightLine); |
204 | | - return highlighted.join('\n'); |
| 144 | + return code.split('\n').map(highlightLine).join('\n'); |
205 | 145 | } |
206 | 146 |
|
207 | | - /** |
208 | | - * Initialize shell syntax highlighting on page load |
209 | | - */ |
210 | 147 | 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"]' |
| 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"]' |
221 | 152 | ]; |
222 | 153 |
|
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; |
| 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); |
234 | 158 | block.setAttribute('data-highlighted', 'true'); |
235 | 159 | }); |
236 | 160 | } |
|
0 commit comments