ftr_ruby 0.1.0 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
data/doc/js/aliki.js ADDED
@@ -0,0 +1,511 @@
1
+ 'use strict';
2
+
3
+ /* ===== Method Source Code Toggling ===== */
4
+
5
+ function showSource(e) {
6
+ let target = e.target;
7
+ while (!target.classList.contains('method-detail')) {
8
+ target = target.parentNode;
9
+ }
10
+ if (typeof target !== "undefined" && target !== null) {
11
+ target = target.querySelector('.method-source-code');
12
+ }
13
+ if (typeof target !== "undefined" && target !== null) {
14
+ target.classList.toggle('active-menu')
15
+ }
16
+ }
17
+
18
+ function hookSourceViews() {
19
+ document.querySelectorAll('.method-source-toggle').forEach((codeObject) => {
20
+ codeObject.addEventListener('click', showSource);
21
+ });
22
+ }
23
+
24
+ /* ===== Search Functionality ===== */
25
+
26
+ function createSearchInstance(input, result) {
27
+ if (!input || !result) return null;
28
+
29
+ result.classList.remove("initially-hidden");
30
+
31
+ const search = new SearchController(search_data, input, result);
32
+
33
+ search.renderItem = function(result) {
34
+ const li = document.createElement('li');
35
+ let html = '';
36
+
37
+ // TODO add relative path to <script> per-page
38
+ html += `<p class="search-match"><a href="${index_rel_prefix}${this.escapeHTML(result.path)}">${this.hlt(result.title)}`;
39
+ if (result.params)
40
+ html += `<span class="params">${result.params}</span>`;
41
+ html += '</a>';
42
+
43
+ // Add type indicator
44
+ if (result.type) {
45
+ const typeLabel = this.formatType(result.type);
46
+ const typeClass = result.type.replace(/_/g, '-');
47
+ html += `<span class="search-type search-type-${this.escapeHTML(typeClass)}">${typeLabel}</span>`;
48
+ }
49
+
50
+ if (result.snippet)
51
+ html += `<div class="search-snippet">${result.snippet}</div>`;
52
+
53
+ li.innerHTML = html;
54
+
55
+ return li;
56
+ }
57
+
58
+ search.formatType = function(type) {
59
+ const typeLabels = {
60
+ 'class': 'class',
61
+ 'module': 'module',
62
+ 'constant': 'const',
63
+ 'instance_method': 'method',
64
+ 'class_method': 'method'
65
+ };
66
+ return typeLabels[type] || type;
67
+ }
68
+
69
+ search.select = function(result) {
70
+ window.location.href = result.firstChild.firstChild.href;
71
+ }
72
+
73
+ return search;
74
+ }
75
+
76
+ function hookSearch() {
77
+ const input = document.querySelector('#search-field');
78
+ const result = document.querySelector('#search-results-desktop');
79
+
80
+ if (!input || !result) return; // Exit if search elements not found
81
+
82
+ const search_section = document.querySelector('#search-section');
83
+ if (search_section) {
84
+ search_section.classList.remove("initially-hidden");
85
+ }
86
+
87
+ const search = createSearchInstance(input, result);
88
+ if (!search) return;
89
+
90
+ // Hide search results when clicking outside the search area
91
+ document.addEventListener('click', (e) => {
92
+ if (!e.target.closest('.navbar-search-desktop')) {
93
+ search.hide();
94
+ }
95
+ });
96
+
97
+ // Hide search results on Escape key on desktop too
98
+ document.addEventListener('keydown', (e) => {
99
+ if (e.key === 'Escape' && input.matches(":focus")) {
100
+ search.hide();
101
+ input.blur();
102
+ }
103
+ });
104
+
105
+ // Show search results when focusing on input (if there's a query)
106
+ input.addEventListener('focus', () => {
107
+ if (input.value.trim()) {
108
+ search.show();
109
+ }
110
+ });
111
+
112
+ // Check for ?q= URL parameter and trigger search automatically
113
+ if (typeof URLSearchParams !== 'undefined') {
114
+ const urlParams = new URLSearchParams(window.location.search);
115
+ const queryParam = urlParams.get('q');
116
+ if (queryParam) {
117
+ input.value = queryParam;
118
+ search.search(queryParam, false);
119
+ }
120
+ }
121
+ }
122
+
123
+ /* ===== Keyboard Shortcuts ===== */
124
+
125
+ function hookFocus() {
126
+ document.addEventListener("keydown", (event) => {
127
+ if (document.activeElement.tagName === 'INPUT') {
128
+ return;
129
+ }
130
+ if (event.key === "/") {
131
+ event.preventDefault();
132
+ document.querySelector('#search-field').focus();
133
+ }
134
+ });
135
+ }
136
+
137
+ /* ===== Mobile Navigation ===== */
138
+
139
+ function hookSidebar() {
140
+ const navigation = document.querySelector('#navigation');
141
+ const navigationToggle = document.querySelector('#navigation-toggle');
142
+
143
+ if (!navigation || !navigationToggle) return;
144
+
145
+ const closeNav = () => {
146
+ navigation.hidden = true;
147
+ navigationToggle.ariaExpanded = 'false';
148
+ document.body.classList.remove('nav-open');
149
+ };
150
+
151
+ const openNav = () => {
152
+ navigation.hidden = false;
153
+ navigationToggle.ariaExpanded = 'true';
154
+ document.body.classList.add('nav-open');
155
+ };
156
+
157
+ const toggleNav = () => {
158
+ if (navigation.hidden) {
159
+ openNav();
160
+ } else {
161
+ closeNav();
162
+ }
163
+ };
164
+
165
+ navigationToggle.addEventListener('click', (e) => {
166
+ e.stopPropagation();
167
+ toggleNav();
168
+ });
169
+
170
+ const isSmallViewport = window.matchMedia("(max-width: 1023px)").matches;
171
+
172
+ // The sidebar is hidden by default with the `hidden` attribute
173
+ // On large viewports, we display the sidebar with JavaScript
174
+ // This is better than the opposite approach of hiding it with JavaScript
175
+ // because it avoids flickering the sidebar when the page is loaded, especially on mobile devices
176
+ if (isSmallViewport) {
177
+ // Close nav when clicking links inside it
178
+ document.addEventListener('click', (e) => {
179
+ if (e.target.closest('#navigation a')) {
180
+ closeNav();
181
+ }
182
+ });
183
+
184
+ // Close nav when clicking backdrop
185
+ document.addEventListener('click', (e) => {
186
+ if (!navigation.hidden &&
187
+ !e.target.closest('#navigation') &&
188
+ !e.target.closest('#navigation-toggle')) {
189
+ closeNav();
190
+ }
191
+ });
192
+ } else {
193
+ openNav();
194
+ }
195
+ }
196
+
197
+ /* ===== Right Sidebar Table of Contents ===== */
198
+
199
+ function generateToc() {
200
+ const tocNav = document.querySelector('#toc-nav');
201
+ if (!tocNav) return; // Exit if TOC nav doesn't exist
202
+
203
+ const main = document.querySelector('main');
204
+ if (!main) return;
205
+
206
+ // Find all h2 and h3 headings in the main content
207
+ const headings = main.querySelectorAll('h1, h2, h3');
208
+ if (headings.length === 0) return;
209
+
210
+ const tocList = document.createElement('ul');
211
+ tocList.className = 'toc-list';
212
+
213
+ headings.forEach((heading) => {
214
+ // Skip if heading doesn't have an id
215
+ if (!heading.id) return;
216
+
217
+ const li = document.createElement('li');
218
+ const level = heading.tagName.toLowerCase();
219
+ li.className = `toc-item toc-${level}`;
220
+
221
+ const link = document.createElement('a');
222
+ link.href = `#${heading.id}`;
223
+ link.className = 'toc-link';
224
+ link.textContent = heading.textContent.trim();
225
+ link.setAttribute('data-target', heading.id);
226
+
227
+ li.appendChild(link);
228
+ setHeadingScrollHandler(heading, link);
229
+ tocList.appendChild(li);
230
+ });
231
+
232
+ if (tocList.children.length > 0) {
233
+ tocNav.appendChild(tocList);
234
+ } else {
235
+ // Hide TOC if no headings found
236
+ const tocContainer = document.querySelector('.table-of-contents');
237
+ if (tocContainer) {
238
+ tocContainer.style.display = 'none';
239
+ }
240
+ }
241
+ }
242
+
243
+ function hookTocActiveHighlighting() {
244
+ const tocLinks = document.querySelectorAll('.toc-link');
245
+ const targetHeadings = [];
246
+ tocLinks.forEach((link) => {
247
+ const targetId = link.getAttribute('data-target');
248
+ const heading = document.getElementById(targetId);
249
+ if (heading) {
250
+ targetHeadings.push(heading);
251
+ }
252
+ });
253
+
254
+ if (targetHeadings.length === 0) return;
255
+
256
+ const observerOptions = {
257
+ root: null,
258
+ rootMargin: '0% 0px -35% 0px',
259
+ threshold: 0
260
+ };
261
+
262
+ const intersectingHeadings = new Set();
263
+ const update = () => {
264
+ const firstIntersectingHeading = targetHeadings.find((heading) => {
265
+ return intersectingHeadings.has(heading);
266
+ });
267
+ if (!firstIntersectingHeading) return;
268
+ const correspondingLink = document.querySelector(`.toc-link[data-target="${firstIntersectingHeading.id}"]`);
269
+ if (!correspondingLink) return;
270
+
271
+ // Remove active class from all links
272
+ tocLinks.forEach((link) => {
273
+ link.classList.remove('active');
274
+ });
275
+
276
+ // Add active class to current link
277
+ correspondingLink.classList.add('active');
278
+
279
+ // Scroll link into view if needed
280
+ const tocNav = document.querySelector('#toc-nav');
281
+ if (tocNav) {
282
+ const linkRect = correspondingLink.getBoundingClientRect();
283
+ const navRect = tocNav.getBoundingClientRect();
284
+
285
+ if (linkRect.top < navRect.top || linkRect.bottom > navRect.bottom) {
286
+ correspondingLink.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
287
+ }
288
+ }
289
+ };
290
+ const observer = new IntersectionObserver((entries) => {
291
+ entries.forEach((entry) => {
292
+ if (entry.isIntersecting) {
293
+ intersectingHeadings.add(entry.target);
294
+ } else {
295
+ intersectingHeadings.delete(entry.target);
296
+ }
297
+ });
298
+ update();
299
+ }, observerOptions);
300
+
301
+ // Observe all headings that have corresponding TOC links
302
+ targetHeadings.forEach((heading) => {
303
+ observer.observe(heading);
304
+ });
305
+ }
306
+
307
+ function setHeadingScrollHandler(heading, link) {
308
+ // Smooth scroll to heading when clicking link
309
+ if (!heading.id) return;
310
+
311
+ link.addEventListener('click', (e) => {
312
+ e.preventDefault();
313
+ heading.scrollIntoView({ behavior: 'smooth', block: 'start' });
314
+ history.pushState(null, '', `#${heading.id}`);
315
+ });
316
+ }
317
+
318
+ function setHeadingSelfLinkScrollHandlers() {
319
+ // Clicking link inside heading scrolls smoothly to heading itself
320
+ const headings = document.querySelectorAll('h1, h2, h3, h4, h5, h6');
321
+ headings.forEach((heading) => {
322
+ if (!heading.id) return;
323
+
324
+ const link = heading.querySelector(`a[href^="#${heading.id}"]`);
325
+ if (link) setHeadingScrollHandler(heading, link);
326
+ })
327
+ }
328
+
329
+ /* ===== Mobile Search Modal ===== */
330
+
331
+ function hookSearchModal() {
332
+ const searchToggle = document.querySelector('#search-toggle');
333
+ const searchModal = document.querySelector('#search-modal');
334
+ const searchModalClose = document.querySelector('#search-modal-close');
335
+ const searchModalBackdrop = document.querySelector('.search-modal-backdrop');
336
+ const searchInput = document.querySelector('#search-field-mobile');
337
+ const searchResults = document.querySelector('#search-results-mobile');
338
+ const searchEmpty = document.querySelector('.search-modal-empty');
339
+
340
+ if (!searchToggle || !searchModal) return;
341
+
342
+ // Initialize search for mobile modal
343
+ const mobileSearch = createSearchInstance(searchInput, searchResults);
344
+ if (!mobileSearch) return;
345
+
346
+ // Hide empty state when there are results
347
+ const originalRenderItem = mobileSearch.renderItem;
348
+ mobileSearch.renderItem = function(result) {
349
+ if (searchEmpty) searchEmpty.style.display = 'none';
350
+ return originalRenderItem.call(this, result);
351
+ };
352
+
353
+ const openSearchModal = () => {
354
+ searchModal.hidden = false;
355
+ document.body.style.overflow = 'hidden';
356
+ // Focus input after animation
357
+ setTimeout(() => {
358
+ if (searchInput) searchInput.focus();
359
+ }, 100);
360
+ };
361
+
362
+ const closeSearchModal = () => {
363
+ searchModal.hidden = true;
364
+ document.body.style.overflow = '';
365
+ };
366
+
367
+ // Open on button click
368
+ searchToggle.addEventListener('click', openSearchModal);
369
+
370
+ // Close on close button click
371
+ if (searchModalClose) {
372
+ searchModalClose.addEventListener('click', closeSearchModal);
373
+ }
374
+
375
+ // Close on backdrop click
376
+ if (searchModalBackdrop) {
377
+ searchModalBackdrop.addEventListener('click', closeSearchModal);
378
+ }
379
+
380
+ // Close on Escape key
381
+ document.addEventListener('keydown', (e) => {
382
+ if (e.key === 'Escape' && !searchModal.hidden) {
383
+ closeSearchModal();
384
+ }
385
+ });
386
+
387
+ // Check for ?q= URL parameter on mobile and open modal
388
+ if (typeof URLSearchParams !== 'undefined') {
389
+ const urlParams = new URLSearchParams(window.location.search);
390
+ const queryParam = urlParams.get('q');
391
+ const isSmallViewport = window.matchMedia("(max-width: 1023px)").matches;
392
+
393
+ if (queryParam && isSmallViewport) {
394
+ openSearchModal();
395
+ searchInput.value = queryParam;
396
+ mobileSearch.search(queryParam, false);
397
+ }
398
+ }
399
+ }
400
+
401
+ /* ===== Code Block Copy Functionality ===== */
402
+
403
+ function createCopyButton() {
404
+ const button = document.createElement('button');
405
+ button.className = 'copy-code-button';
406
+ button.type = 'button';
407
+ button.setAttribute('aria-label', 'Copy code to clipboard');
408
+ button.setAttribute('title', 'Copy code');
409
+
410
+ // Create clipboard icon SVG
411
+ const clipboardIcon = `
412
+ <svg viewBox="0 0 24 24">
413
+ <rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect>
414
+ <path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path>
415
+ </svg>
416
+ `;
417
+
418
+ // Create checkmark icon SVG (for copied state)
419
+ const checkIcon = `
420
+ <svg viewBox="0 0 24 24">
421
+ <polyline points="20 6 9 17 4 12"></polyline>
422
+ </svg>
423
+ `;
424
+
425
+ button.innerHTML = clipboardIcon;
426
+ button.dataset.clipboardIcon = clipboardIcon;
427
+ button.dataset.checkIcon = checkIcon;
428
+
429
+ return button;
430
+ }
431
+
432
+ function wrapCodeBlocksWithCopyButton() {
433
+ // Copy buttons are generated dynamically rather than statically in rhtml templates because:
434
+ // - Code blocks are generated by RDoc's markup formatter (RDoc::Markup::ToHtml),
435
+ // not directly in rhtml templates
436
+ // - Modifying the formatter would require extending RDoc's core internals
437
+
438
+ // Find all pre elements that are not already wrapped
439
+ const preElements = document.querySelectorAll('main pre:not(.code-block-wrapper pre)');
440
+
441
+ preElements.forEach((pre) => {
442
+ // Skip if already wrapped
443
+ if (pre.parentElement.classList.contains('code-block-wrapper')) {
444
+ return;
445
+ }
446
+
447
+ // Create wrapper
448
+ const wrapper = document.createElement('div');
449
+ wrapper.className = 'code-block-wrapper';
450
+
451
+ // Insert wrapper before pre
452
+ pre.parentNode.insertBefore(wrapper, pre);
453
+
454
+ // Move pre into wrapper
455
+ wrapper.appendChild(pre);
456
+
457
+ // Create and add copy button
458
+ const copyButton = createCopyButton();
459
+ wrapper.appendChild(copyButton);
460
+
461
+ // Add click handler
462
+ copyButton.addEventListener('click', () => {
463
+ copyCodeToClipboard(pre, copyButton);
464
+ });
465
+ });
466
+ }
467
+
468
+ function copyCodeToClipboard(preElement, button) {
469
+ const code = preElement.textContent;
470
+
471
+ // Use the Clipboard API (supported by all modern browsers)
472
+ if (navigator.clipboard && navigator.clipboard.writeText) {
473
+ navigator.clipboard.writeText(code).then(() => {
474
+ showCopySuccess(button);
475
+ }).catch(() => {
476
+ alert('Failed to copy code.');
477
+ });
478
+ } else {
479
+ alert('Failed to copy code.');
480
+ }
481
+ }
482
+
483
+ function showCopySuccess(button) {
484
+ // Change icon to checkmark
485
+ button.innerHTML = button.dataset.checkIcon;
486
+ button.classList.add('copied');
487
+ button.setAttribute('aria-label', 'Copied!');
488
+ button.setAttribute('title', 'Copied!');
489
+
490
+ // Revert back after 2 seconds
491
+ setTimeout(() => {
492
+ button.innerHTML = button.dataset.clipboardIcon;
493
+ button.classList.remove('copied');
494
+ button.setAttribute('aria-label', 'Copy code to clipboard');
495
+ button.setAttribute('title', 'Copy code');
496
+ }, 2000);
497
+ }
498
+
499
+ /* ===== Initialization ===== */
500
+
501
+ document.addEventListener('DOMContentLoaded', () => {
502
+ hookSourceViews();
503
+ hookSearch();
504
+ hookFocus();
505
+ hookSidebar();
506
+ generateToc();
507
+ setHeadingSelfLinkScrollHandlers();
508
+ hookTocActiveHighlighting();
509
+ hookSearchModal();
510
+ wrapCodeBlocksWithCopyButton();
511
+ });
@@ -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, /abs/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
+ })();