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