This commit is contained in:
julien committed 2026-10-10 14:57:27 +02:00
1 parent d342ca5e85
commit 3e474063c4
17 files changed
+6827 -913

No files matched your search

+505
View File
@@ -0,0 +1,505 @@
'use strict';
/* ===== Method Source Code Toggling ===== */
function showSource(e) {
let target = e.target;
while (!target.classList.contains('method-detail')) {
target = target.parentNode;
}
if (typeof target !== "undefined" && target !== null) {
target = target.querySelector('.method-source-code');
}
if (typeof target !== "undefined" && target !== null) {
target.classList.toggle('active-menu')
}
}
function hookSourceViews() {
document.querySelectorAll('.method-source-toggle').forEach((codeObject) => {
codeObject.addEventListener('click', showSource);
});
}
/* ===== Search Functionality ===== */
function createSearchInstance(input, result) {
if (!input || !result) return null;
result.classList.remove("initially-hidden");
const search = new SearchController(search_data, input, result);
search.renderItem = function(result) {
const li = document.createElement('li');
let html = '';
// TODO add relative path to <script> per-page
html += `<p class="search-match"><a href="${index_rel_prefix}${this.escapeHTML(result.path)}">${this.hlt(result.title)}`;
if (result.params)
html += `<span class="params">${result.params}</span>`;
html += '</a>';
// Add type indicator
if (result.type) {
const typeLabel = this.formatType(result.type);
const typeClass = result.type.replace(/_/g, '-');
html += `<span class="search-type search-type-${this.escapeHTML(typeClass)}">${typeLabel}</span>`;
}
if (result.snippet)
html += `<div class="search-snippet">${result.snippet}</div>`;
li.innerHTML = html;
return li;
}
search.formatType = function(type) {
const typeLabels = {
'class': 'class',
'module': 'module',
'constant': 'const',
'instance_method': 'method',
'class_method': 'method'
};
return typeLabels[type] || type;
}
search.select = function(result) {
window.location.href = result.firstChild.firstChild.href;
}
search.scrollIntoView = search.scrollInWindow;
return search;
}
function hookSearch() {
const input = document.querySelector('#search-field');
const result = document.querySelector('#search-results-desktop');
if (!input || !result) return; // Exit if search elements not found
const search_section = document.querySelector('#search-section');
if (search_section) {
search_section.classList.remove("initially-hidden");
}
const search = createSearchInstance(input, result);
if (!search) return;
// Hide search results when clicking outside the search area
document.addEventListener('click', (e) => {
if (!e.target.closest('.navbar-search-desktop')) {
search.hide();
}
});
// Show search results when focusing on input (if there's a query)
input.addEventListener('focus', () => {
if (input.value.trim()) {
search.show();
}
});
// Check for ?q= URL parameter and trigger search automatically
if (typeof URLSearchParams !== 'undefined') {
const urlParams = new URLSearchParams(window.location.search);
const queryParam = urlParams.get('q');
if (queryParam) {
input.value = queryParam;
search.search(queryParam, false);
}
}
}
/* ===== Keyboard Shortcuts ===== */
function hookFocus() {
document.addEventListener("keydown", (event) => {
if (document.activeElement.tagName === 'INPUT') {
return;
}
if (event.key === "/") {
event.preventDefault();
document.querySelector('#search-field').focus();
}
});
}
/* ===== Mobile Navigation ===== */
function hookSidebar() {
const navigation = document.querySelector('#navigation');
const navigationToggle = document.querySelector('#navigation-toggle');
if (!navigation || !navigationToggle) return;
const closeNav = () => {
navigation.hidden = true;
navigationToggle.ariaExpanded = 'false';
document.body.classList.remove('nav-open');
};
const openNav = () => {
navigation.hidden = false;
navigationToggle.ariaExpanded = 'true';
document.body.classList.add('nav-open');
};
const toggleNav = () => {
if (navigation.hidden) {
openNav();
} else {
closeNav();
}
};
navigationToggle.addEventListener('click', (e) => {
e.stopPropagation();
toggleNav();
});
const isSmallViewport = window.matchMedia("(max-width: 1023px)").matches;
// The sidebar is hidden by default with the `hidden` attribute
// On large viewports, we display the sidebar with JavaScript
// This is better than the opposite approach of hiding it with JavaScript
// because it avoids flickering the sidebar when the page is loaded, especially on mobile devices
if (isSmallViewport) {
// Close nav when clicking links inside it
document.addEventListener('click', (e) => {
if (e.target.closest('#navigation a')) {
closeNav();
}
});
// Close nav when clicking backdrop
document.addEventListener('click', (e) => {
if (!navigation.hidden &&
!e.target.closest('#navigation') &&
!e.target.closest('#navigation-toggle')) {
closeNav();
}
});
} else {
openNav();
}
}
/* ===== Right Sidebar Table of Contents ===== */
function generateToc() {
const tocNav = document.querySelector('#toc-nav');
if (!tocNav) return; // Exit if TOC nav doesn't exist
const main = document.querySelector('main');
if (!main) return;
// Find all h2 and h3 headings in the main content
const headings = main.querySelectorAll('h1, h2, h3');
if (headings.length === 0) return;
const tocList = document.createElement('ul');
tocList.className = 'toc-list';
headings.forEach((heading) => {
// Skip if heading doesn't have an id
if (!heading.id) return;
const li = document.createElement('li');
const level = heading.tagName.toLowerCase();
li.className = `toc-item toc-${level}`;
const link = document.createElement('a');
link.href = `#${heading.id}`;
link.className = 'toc-link';
link.textContent = heading.textContent.trim();
link.setAttribute('data-target', heading.id);
li.appendChild(link);
setHeadingScrollHandler(heading, link);
tocList.appendChild(li);
});
if (tocList.children.length > 0) {
tocNav.appendChild(tocList);
} else {
// Hide TOC if no headings found
const tocContainer = document.querySelector('.table-of-contents');
if (tocContainer) {
tocContainer.style.display = 'none';
}
}
}
function hookTocActiveHighlighting() {
const tocLinks = document.querySelectorAll('.toc-link');
const targetHeadings = [];
tocLinks.forEach((link) => {
const targetId = link.getAttribute('data-target');
const heading = document.getElementById(targetId);
if (heading) {
targetHeadings.push(heading);
}
});
if (targetHeadings.length === 0) return;
const observerOptions = {
root: null,
rootMargin: '0% 0px -35% 0px',
threshold: 0
};
const intersectingHeadings = new Set();
const update = () => {
const firstIntersectingHeading = targetHeadings.find((heading) => {
return intersectingHeadings.has(heading);
});
if (!firstIntersectingHeading) return;
const correspondingLink = document.querySelector(`.toc-link[data-target="${firstIntersectingHeading.id}"]`);
if (!correspondingLink) return;
// Remove active class from all links
tocLinks.forEach((link) => {
link.classList.remove('active');
});
// Add active class to current link
correspondingLink.classList.add('active');
// Scroll link into view if needed
const tocNav = document.querySelector('#toc-nav');
if (tocNav) {
const linkRect = correspondingLink.getBoundingClientRect();
const navRect = tocNav.getBoundingClientRect();
if (linkRect.top < navRect.top || linkRect.bottom > navRect.bottom) {
correspondingLink.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}
}
};
const observer = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
intersectingHeadings.add(entry.target);
} else {
intersectingHeadings.delete(entry.target);
}
});
update();
}, observerOptions);
// Observe all headings that have corresponding TOC links
targetHeadings.forEach((heading) => {
observer.observe(heading);
});
}
function setHeadingScrollHandler(heading, link) {
// Smooth scroll to heading when clicking link
if (!heading.id) return;
link.addEventListener('click', (e) => {
e.preventDefault();
heading.scrollIntoView({ behavior: 'smooth', block: 'start' });
history.pushState(null, '', `#${heading.id}`);
});
}
function setHeadingSelfLinkScrollHandlers() {
// Clicking link inside heading scrolls smoothly to heading itself
const headings = document.querySelectorAll('h1, h2, h3, h4, h5, h6');
headings.forEach((heading) => {
if (!heading.id) return;
const link = heading.querySelector(`a[href^="#${heading.id}"]`);
if (link) setHeadingScrollHandler(heading, link);
})
}
/* ===== Mobile Search Modal ===== */
function hookSearchModal() {
const searchToggle = document.querySelector('#search-toggle');
const searchModal = document.querySelector('#search-modal');
const searchModalClose = document.querySelector('#search-modal-close');
const searchModalBackdrop = document.querySelector('.search-modal-backdrop');
const searchInput = document.querySelector('#search-field-mobile');
const searchResults = document.querySelector('#search-results-mobile');
const searchEmpty = document.querySelector('.search-modal-empty');
if (!searchToggle || !searchModal) return;
// Initialize search for mobile modal
const mobileSearch = createSearchInstance(searchInput, searchResults);
if (!mobileSearch) return;
// Hide empty state when there are results
const originalRenderItem = mobileSearch.renderItem;
mobileSearch.renderItem = function(result) {
if (searchEmpty) searchEmpty.style.display = 'none';
return originalRenderItem.call(this, result);
};
const openSearchModal = () => {
searchModal.hidden = false;
document.body.style.overflow = 'hidden';
// Focus input after animation
setTimeout(() => {
if (searchInput) searchInput.focus();
}, 100);
};
const closeSearchModal = () => {
searchModal.hidden = true;
document.body.style.overflow = '';
};
// Open on button click
searchToggle.addEventListener('click', openSearchModal);
// Close on close button click
if (searchModalClose) {
searchModalClose.addEventListener('click', closeSearchModal);
}
// Close on backdrop click
if (searchModalBackdrop) {
searchModalBackdrop.addEventListener('click', closeSearchModal);
}
// Close on Escape key
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && !searchModal.hidden) {
closeSearchModal();
}
});
// Check for ?q= URL parameter on mobile and open modal
if (typeof URLSearchParams !== 'undefined') {
const urlParams = new URLSearchParams(window.location.search);
const queryParam = urlParams.get('q');
const isSmallViewport = window.matchMedia("(max-width: 1023px)").matches;
if (queryParam && isSmallViewport) {
openSearchModal();
searchInput.value = queryParam;
mobileSearch.search(queryParam, false);
}
}
}
/* ===== Code Block Copy Functionality ===== */
function createCopyButton() {
const button = document.createElement('button');
button.className = 'copy-code-button';
button.type = 'button';
button.setAttribute('aria-label', 'Copy code to clipboard');
button.setAttribute('title', 'Copy code');
// Create clipboard icon SVG
const clipboardIcon = `
<svg viewBox="0 0 24 24">
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect>
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path>
</svg>
`;
// Create checkmark icon SVG (for copied state)
const checkIcon = `
<svg viewBox="0 0 24 24">
<polyline points="20 6 9 17 4 12"></polyline>
</svg>
`;
button.innerHTML = clipboardIcon;
button.dataset.clipboardIcon = clipboardIcon;
button.dataset.checkIcon = checkIcon;
return button;
}
function wrapCodeBlocksWithCopyButton() {
// Copy buttons are generated dynamically rather than statically in rhtml templates because:
// - Code blocks are generated by RDoc's markup formatter (RDoc::Markup::ToHtml),
// not directly in rhtml templates
// - Modifying the formatter would require extending RDoc's core internals
// Find all pre elements that are not already wrapped
const preElements = document.querySelectorAll('main pre:not(.code-block-wrapper pre)');
preElements.forEach((pre) => {
// Skip if already wrapped
if (pre.parentElement.classList.contains('code-block-wrapper')) {
return;
}
// Create wrapper
const wrapper = document.createElement('div');
wrapper.className = 'code-block-wrapper';
// Insert wrapper before pre
pre.parentNode.insertBefore(wrapper, pre);
// Move pre into wrapper
wrapper.appendChild(pre);
// Create and add copy button
const copyButton = createCopyButton();
wrapper.appendChild(copyButton);
// Add click handler
copyButton.addEventListener('click', () => {
copyCodeToClipboard(pre, copyButton);
});
});
}
function copyCodeToClipboard(preElement, button) {
const code = preElement.textContent;
// Use the Clipboard API (supported by all modern browsers)
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(code).then(() => {
showCopySuccess(button);
}).catch(() => {
alert('Failed to copy code.');
});
} else {
alert('Failed to copy code.');
}
}
function showCopySuccess(button) {
// Change icon to checkmark
button.innerHTML = button.dataset.checkIcon;
button.classList.add('copied');
button.setAttribute('aria-label', 'Copied!');
button.setAttribute('title', 'Copied!');
// Revert back after 2 seconds
setTimeout(() => {
button.innerHTML = button.dataset.clipboardIcon;
button.classList.remove('copied');
button.setAttribute('aria-label', 'Copy code to clipboard');
button.setAttribute('title', 'Copy code');
}, 2000);
}
/* ===== Initialization ===== */
document.addEventListener('DOMContentLoaded', () => {
hookSourceViews();
hookSearch();
hookFocus();
hookSidebar();
generateToc();
setHeadingSelfLinkScrollHandlers();
hookTocActiveHighlighting();
hookSearchModal();
wrapCodeBlocksWithCopyButton();
});
+299
View File
@@ -0,0 +1,299 @@
/**
* Client-side C syntax highlighter for RDoc
*/
(function() {
'use strict';
// C control flow and storage class keywords
const C_KEYWORDS = new Set([
'auto', 'break', 'case', 'continue', 'default', 'do', 'else', 'extern',
'for', 'goto', 'if', 'inline', 'register', 'return', 'sizeof', 'static',
'switch', 'while',
'_Alignas', '_Alignof', '_Generic', '_Noreturn', '_Static_assert', '_Thread_local'
]);
// C type keywords and type qualifiers
const C_TYPE_KEYWORDS = new Set([
'bool', 'char', 'const', 'double', 'enum', 'float', 'int', 'long',
'restrict', 'short', 'signed', 'struct', 'typedef', 'union', 'unsigned',
'void', 'volatile', '_Atomic', '_Bool', '_Complex', '_Imaginary'
]);
// Library-defined types (typedef'd in headers, not language keywords)
// Includes: Ruby C API types (VALUE, ID), POSIX types (size_t, ssize_t),
// fixed-width integer types (uint32_t, int64_t), and standard I/O types (FILE)
const C_TYPES = new Set([
'VALUE', 'ID', 'size_t', 'ssize_t', 'ptrdiff_t', 'uintptr_t', 'intptr_t',
'uint8_t', 'uint16_t', 'uint32_t', 'uint64_t',
'int8_t', 'int16_t', 'int32_t', 'int64_t',
'FILE', 'DIR', 'va_list'
]);
// Common Ruby VALUE macros and boolean literals
const RUBY_MACROS = new Set([
'Qtrue', 'Qfalse', 'Qnil', 'Qundef', 'NULL', 'TRUE', 'FALSE', 'true', 'false'
]);
const OPERATORS = new Set([
'==', '!=', '<=', '>=', '&&', '||', '<<', '>>', '++', '--',
'+=', '-=', '*=', '/=', '%=', '&=', '|=', '^=', '->',
'+', '-', '*', '/', '%', '<', '>', '=', '!', '&', '|', '^', '~'
]);
// Single character that can start an operator
const OPERATOR_CHARS = new Set('+-*/%<>=!&|^~');
function isMacro(word) {
return RUBY_MACROS.has(word) || /^[A-Z][A-Z0-9_]*$/.test(word);
}
function isType(word) {
return C_TYPE_KEYWORDS.has(word) || C_TYPES.has(word) || /_t$/.test(word);
}
/**
* Escape HTML special characters
*/
function escapeHtml(text) {
return text
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
/**
* Check if position is at line start (only whitespace before it)
*/
function isLineStart(code, pos) {
if (pos === 0) return true;
for (let i = pos - 1; i >= 0; i--) {
const ch = code[i];
if (ch === '\n') return true;
if (ch !== ' ' && ch !== '\t') return false;
}
return true;
}
/**
* Highlight C source code
*/
function highlightC(code) {
const tokens = [];
let i = 0;
const len = code.length;
while (i < len) {
const char = code[i];
// Multi-line comment
if (char === '/' && code[i + 1] === '*') {
let end = code.indexOf('*/', i + 2);
end = (end === -1) ? len : end + 2;
const comment = code.substring(i, end);
tokens.push('<span class="c-comment">', escapeHtml(comment), '</span>');
i = end;
continue;
}
// Single-line comment
if (char === '/' && code[i + 1] === '/') {
const end = code.indexOf('\n', i);
const commentEnd = (end === -1) ? len : end;
const comment = code.substring(i, commentEnd);
tokens.push('<span class="c-comment">', escapeHtml(comment), '</span>');
i = commentEnd;
continue;
}
// Preprocessor directive (must be at line start)
if (char === '#' && isLineStart(code, i)) {
let end = i + 1;
while (end < len && code[end] !== '\n') {
if (code[end] === '\\' && end + 1 < len && code[end + 1] === '\n') {
end += 2; // Handle line continuation
} else {
end++;
}
}
const preprocessor = code.substring(i, end);
tokens.push('<span class="c-preprocessor">', escapeHtml(preprocessor), '</span>');
i = end;
continue;
}
// String literal
if (char === '"') {
let end = i + 1;
while (end < len && code[end] !== '"') {
if (code[end] === '\\' && end + 1 < len) {
end += 2; // Skip escaped character
} else {
end++;
}
}
if (end < len) end++; // Include closing quote
const string = code.substring(i, end);
tokens.push('<span class="c-string">', escapeHtml(string), '</span>');
i = end;
continue;
}
// Character literal
if (char === "'") {
let end = i + 1;
// Handle escape sequences like '\n', '\\', '\''
if (end < len && code[end] === '\\' && end + 1 < len) {
end += 2; // Skip backslash and escaped char
} else if (end < len) {
end++; // Single character
}
if (end < len && code[end] === "'") end++; // Closing quote
const charLit = code.substring(i, end);
tokens.push('<span class="c-value">', escapeHtml(charLit), '</span>');
i = end;
continue;
}
// Number (integer or float)
if (char >= '0' && char <= '9') {
let end = i;
// Hexadecimal
if (char === '0' && (code[i + 1] === 'x' || code[i + 1] === 'X')) {
end = i + 2;
while (end < len) {
const ch = code[end];
if ((ch >= '0' && ch <= '9') || (ch >= 'a' && ch <= 'f') || (ch >= 'A' && ch <= 'F')) {
end++;
} else {
break;
}
}
}
// Octal
else if (char === '0' && code[i + 1] >= '0' && code[i + 1] <= '7') {
end = i + 1;
while (end < len && code[end] >= '0' && code[end] <= '7') end++;
}
// Decimal/Float
else {
while (end < len) {
const ch = code[end];
if ((ch >= '0' && ch <= '9') || ch === '.') {
end++;
} else {
break;
}
}
// Scientific notation
if (end < len && (code[end] === 'e' || code[end] === 'E')) {
end++;
if (end < len && (code[end] === '+' || code[end] === '-')) end++;
while (end < len && code[end] >= '0' && code[end] <= '9') end++;
}
}
// Suffix (u, l, f, etc.)
while (end < len) {
const ch = code[end];
if (ch === 'u' || ch === 'U' || ch === 'l' || ch === 'L' || ch === 'f' || ch === 'F') {
end++;
} else {
break;
}
}
const number = code.substring(i, end);
tokens.push('<span class="c-value">', escapeHtml(number), '</span>');
i = end;
continue;
}
// Identifier or keyword
if ((char >= 'a' && char <= 'z') || (char >= 'A' && char <= 'Z') || char === '_') {
let end = i + 1;
while (end < len) {
const ch = code[end];
if ((ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z') ||
(ch >= '0' && ch <= '9') || ch === '_') {
end++;
} else {
break;
}
}
const word = code.substring(i, end);
if (C_KEYWORDS.has(word)) {
tokens.push('<span class="c-keyword">', escapeHtml(word), '</span>');
} else if (isType(word)) {
// Check types before macros (VALUE, ID are types, not macros)
tokens.push('<span class="c-type">', escapeHtml(word), '</span>');
} else if (isMacro(word)) {
tokens.push('<span class="c-macro">', escapeHtml(word), '</span>');
} else {
// Check if followed by '(' -> function name
let nextCharIdx = end;
while (nextCharIdx < len && (code[nextCharIdx] === ' ' || code[nextCharIdx] === '\t')) {
nextCharIdx++;
}
if (nextCharIdx < len && code[nextCharIdx] === '(') {
tokens.push('<span class="c-function">', escapeHtml(word), '</span>');
} else {
tokens.push('<span class="c-identifier">', escapeHtml(word), '</span>');
}
}
i = end;
continue;
}
// Operators
if (OPERATOR_CHARS.has(char)) {
let op = char;
// Check for two-character operators
if (i + 1 < len) {
const twoChar = char + code[i + 1];
if (OPERATORS.has(twoChar)) {
op = twoChar;
}
}
tokens.push('<span class="c-operator">', escapeHtml(op), '</span>');
i += op.length;
continue;
}
// Everything else (punctuation, whitespace)
tokens.push(escapeHtml(char));
i++;
}
return tokens.join('');
}
/**
* Initialize C syntax highlighting on page load
*/
function initHighlighting() {
const codeBlocks = document.querySelectorAll('pre[data-language="c"]');
codeBlocks.forEach(block => {
if (block.getAttribute('data-highlighted') === 'true') {
return;
}
const code = block.textContent;
const highlighted = highlightC(code);
block.innerHTML = highlighted;
block.setAttribute('data-highlighted', 'true');
});
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initHighlighting);
} else {
initHighlighting();
}
})();
+129
View File
@@ -0,0 +1,129 @@
SearchController = function(data, input, result) {
this.data = data;
this.input = input;
this.result = result;
this.current = null;
this.view = this.result.parentNode;
this.ranker = new SearchRanker(data.index);
this.init();
}
SearchController.prototype = Object.assign({}, SearchNavigation, new function() {
var suid = 1;
this.init = function() {
var _this = this;
var observer = function(e) {
switch(e.key) {
case 'ArrowUp':
case 'ArrowDown':
return;
}
_this.search(_this.input.value);
};
this.input.addEventListener('keyup', observer);
this.input.addEventListener('click', observer); // mac's clear field
this.ranker.ready(function(results, isLast) {
_this.addResults(results, isLast);
})
this.initNavigation();
this.setNavigationActive(false);
}
this.search = function(value, selectFirstMatch) {
this.selectFirstMatch = selectFirstMatch;
value = value.trim();
if (value) {
this.setNavigationActive(true);
} else {
this.setNavigationActive(false);
}
if (value == '') {
this.lastQuery = value;
this.result.innerHTML = '';
this.result.setAttribute('aria-expanded', 'false');
this.setNavigationActive(false);
} else if (value != this.lastQuery) {
this.lastQuery = value;
this.result.setAttribute('aria-busy', 'true');
this.result.setAttribute('aria-expanded', 'true');
this.firstRun = true;
this.ranker.find(value);
}
}
this.addResults = function(results, isLast) {
var target = this.result;
if (this.firstRun && (results.length > 0 || isLast)) {
this.current = null;
this.result.innerHTML = '';
}
for (var i=0, l = results.length; i < l; i++) {
var item = this.renderItem.call(this, results[i]);
item.setAttribute('id', 'search-result-' + target.childElementCount);
target.appendChild(item);
};
if (this.firstRun && results.length > 0) {
this.firstRun = false;
this.current = target.firstChild;
this.current.classList.add('search-selected');
}
//TODO: ECMAScript
//if (jQuery.browser.msie) this.$element[0].className += '';
if (this.selectFirstMatch && this.current) {
this.selectFirstMatch = false;
this.select(this.current);
}
if (isLast) {
this.selectFirstMatch = false;
this.result.setAttribute('aria-busy', 'false');
}
}
this.move = function(isDown) {
if (!this.current) return;
var next = isDown ? this.current.nextElementSibling : this.current.previousElementSibling;
if (next) {
this.current.classList.remove('search-selected');
next.classList.add('search-selected');
this.input.setAttribute('aria-activedescendant', next.getAttribute('id'));
this.scrollIntoView(next, this.view);
this.current = next;
this.input.value = next.firstChild.firstChild.text;
this.input.select();
}
return true;
}
this.hlt = function(html) {
return this.escapeHTML(html).
replace(/\u0001/g, '<em>').
replace(/\u0002/g, '</em>');
}
this.escapeHTML = function(html) {
return html.replace(/[&<>"`']/g, function(c) {
return '&#' + c.charCodeAt(0) + ';';
});
}
this.hide = function() {
this.result.setAttribute('aria-expanded', 'false');
this.setNavigationActive(false);
}
this.show = function() {
this.result.setAttribute('aria-expanded', 'true');
this.setNavigationActive(true);
}
});
+1
View File
@@ -0,0 +1 @@
var search_data = {"index":[{"name":"Card","full_name":"Card","type":"class","path":"Card.html","snippet":"<p>Représente une carte à jouer avec sa valeur et sa couleur.\n"},{"name":"Deck","full_name":"Deck","type":"class","path":"Deck.html","snippet":"<p>Représente un paquet de cartes et gère son contenu.\n"},{"name":"Game","full_name":"Game","type":"class","path":"Game.html","snippet":"<p>Gère le déroulement et les règles d’une partie d’Écarté.\n"},{"name":"Player","full_name":"Player","type":"class","path":"Player.html","snippet":"<p>Représente un joueur, sa main et son score.\n"},{"name":"TerminalUI","full_name":"TerminalUI","type":"class","path":"TerminalUI.html","snippet":"<p>Gère l’affichage et les saisies dans le terminal.\n"},{"name":"new","full_name":"Card::new","type":"class_method","path":"Card.html#method-c-new"},{"name":"new","full_name":"Deck::new","type":"class_method","path":"Deck.html#method-c-new"},{"name":"new","full_name":"Game::new","type":"class_method","path":"Game.html#method-c-new"},{"name":"new","full_name":"Player::new","type":"class_method","path":"Player.html#method-c-new"},{"name":"add_point","full_name":"Player#add_point","type":"instance_method","path":"Player.html#method-i-add_point","snippet":"<p>Ajoute un point au joueur.\n"},{"name":"change_dealer","full_name":"Game#change_dealer","type":"instance_method","path":"Game.html#method-i-change_dealer","snippet":"<p>Échange les rôles de donneur et de non-donneur.\n"},{"name":"choose_card","full_name":"TerminalUI#choose_card","type":"instance_method","path":"TerminalUI.html#method-i-choose_card","snippet":"<p>Demande au joueur de choisir une carte et la retourne.\n"},{"name":"clear_hand","full_name":"Player#clear_hand","type":"instance_method","path":"Player.html#method-i-clear_hand","snippet":"<p>Vide la main du joueur.\n"},{"name":"deal","full_name":"Game#deal","type":"instance_method","path":"Game.html#method-i-deal","snippet":"<p>Distribue les cartes et pose la retourne.\n"},{"name":"debug_game","full_name":"TerminalUI#debug_game","type":"instance_method","path":"TerminalUI.html#method-i-debug_game","snippet":"<p>Affiche les informations de débogage.\n"},{"name":"determine_dealer","full_name":"Game#determine_dealer","type":"instance_method","path":"Game.html#method-i-determine_dealer","snippet":"<p>Simule une coupe pour déterminer quel joueur sera le donneur.\n"},{"name":"display_trick_winner","full_name":"TerminalUI#display_trick_winner","type":"instance_method","path":"TerminalUI.html#method-i-display_trick_winner","snippet":"<p>Affiche le joueur ayant remporté le pli.\n"},{"name":"display_trump","full_name":"TerminalUI#display_trump","type":"instance_method","path":"TerminalUI.html#method-i-display_trump","snippet":"<p>Affiche la couleur d’atout.\n"},{"name":"display_wrong_card","full_name":"TerminalUI#display_wrong_card","type":"instance_method","path":"TerminalUI.html#method-i-display_wrong_card","snippet":"<p>Affiche un avertissement si le coup joué ne respecte pas les règles.\n"},{"name":"draw","full_name":"Deck#draw","type":"instance_method","path":"Deck.html#method-i-draw","snippet":"<p>Retire et retourne la dernière carte du paquet.\n"},{"name":"game_winner","full_name":"Game#game_winner","type":"instance_method","path":"Game.html#method-i-game_winner","snippet":"<p>Retourne le joueur ayant le meilleur score.\n"},{"name":"other_player","full_name":"Game#other_player","type":"instance_method","path":"Game.html#method-i-other_player","snippet":"<p>Retourne l’autre joueur.\n"},{"name":"play","full_name":"Game#play","type":"instance_method","path":"Game.html#method-i-play","snippet":"<p>Joue une partie.\n"},{"name":"play","full_name":"Player#play","type":"instance_method","path":"Player.html#method-i-play","snippet":"<p>Retire une carte de la main du joueur.\n"},{"name":"play_trick","full_name":"Game#play_trick","type":"instance_method","path":"Game.html#method-i-play_trick","snippet":"<p>Joue un pli.\n"},{"name":"receive","full_name":"Player#receive","type":"instance_method","path":"Player.html#method-i-receive","snippet":"<p>Ajoute une carte à la main du joueur.\n"},{"name":"shuffle","full_name":"Deck#shuffle","type":"instance_method","path":"Deck.html#method-i-shuffle","snippet":"<p>Mélange les cartes du paquet.\n"},{"name":"stock","full_name":"Game#stock","type":"instance_method","path":"Game.html#method-i-stock","snippet":"<p>Retourne les cartes restantes dans le talon.\n"},{"name":"strength","full_name":"Card#strength","type":"instance_method","path":"Card.html#method-i-strength","snippet":"<p>Retourne la force de la carte sous forme d’integer.\n"},{"name":"to_s","full_name":"Card#to_s","type":"instance_method","path":"Card.html#method-i-to_s","snippet":"<p>Retourne une représentation de la carte sous forme de string.\n"},{"name":"valid_follow_card?","full_name":"Game#valid_follow_card?","type":"instance_method","path":"Game.html#method-i-valid_follow_card-3F","snippet":"<p>VérLine truncated
+105
View File
@@ -0,0 +1,105 @@
/*
* SearchNavigation allows movement using the arrow keys through the search results.
*
* When using this library you will need to set scrollIntoView to the
* appropriate function for your layout. Use scrollInWindow if the container
* is not scrollable and scrollInElement if the container is a separate
* scrolling region.
*/
SearchNavigation = new function() {
this.initNavigation = function() {
var _this = this;
document.addEventListener('keydown', function(e) {
_this.onkeydown(e);
});
this.navigationActive = true;
}
this.setNavigationActive = function(state) {
this.navigationActive = state;
}
this.onkeydown = function(e) {
if (!this.navigationActive) return;
switch(e.key) {
case 'ArrowLeft':
if (this.moveLeft()) e.preventDefault();
break;
case 'ArrowUp':
if (e.key == 'ArrowUp' || e.ctrlKey) {
if (this.moveUp()) e.preventDefault();
}
break;
case 'ArrowRight':
if (this.moveRight()) e.preventDefault();
break;
case 'ArrowDown':
if (e.key == 'ArrowDown' || e.ctrlKey) {
if (this.moveDown()) e.preventDefault();
}
break;
case 'Enter':
if (this.current) e.preventDefault();
this.select(this.current);
break;
}
if (e.ctrlKey && e.shiftKey) this.select(this.current);
}
this.moveRight = function() {
}
this.moveLeft = function() {
}
this.move = function(isDown) {
}
this.moveUp = function() {
return this.move(false);
}
this.moveDown = function() {
return this.move(true);
}
/*
* Scrolls to the given element in the scrollable element view.
*/
this.scrollInElement = function(element, view) {
var offset, viewHeight, viewScroll, height;
offset = element.offsetTop;
height = element.offsetHeight;
viewHeight = view.offsetHeight;
viewScroll = view.scrollTop;
if (offset - viewScroll + height > viewHeight) {
view.scrollTop = offset - viewHeight + height;
}
if (offset < viewScroll) {
view.scrollTop = offset;
}
}
/*
* Scrolls to the given element in the window. The second argument is
* ignored
*/
this.scrollInWindow = function(element, ignored) {
var offset, viewHeight, viewScroll, height;
offset = element.offsetTop;
height = element.offsetHeight;
viewHeight = window.innerHeight;
viewScroll = window.scrollY;
if (offset - viewScroll + height > viewHeight) {
window.scrollTo(window.scrollX, offset - viewHeight + height);
}
if (offset < viewScroll) {
window.scrollTo(window.scrollX, offset);
}
}
}
+239
View File
@@ -0,0 +1,239 @@
/**
* Aliki Search Implementation
*
* Search algorithm with the following priorities:
* 1. Exact full_name match always wins (for namespace/method queries)
* 2. Exact name match gets high priority
* 3. Match types:
* - Namespace queries (::) and method queries (# or .) match against full_name
* - Regular queries match against unqualified name
* - Prefix (10000) > substring (5000) > fuzzy (1000)
* 4. First character determines type priority:
* - Starts with lowercase: methods first
* - Starts with uppercase: classes/modules/constants first
* 5. Within same type priority:
* - Unqualified match > qualified match
* - Shorter name > longer name
* 6. Class methods > instance methods
* 7. Result limit: 30
* 8. Minimum query length: 1 character
*/
var MAX_RESULTS = 30;
var MIN_QUERY_LENGTH = 1;
/*
* Scoring constants - organized in tiers where each tier dominates lower tiers.
* This ensures match type always beats type priority, etc.
*
* Tier 0: Exact matches (immediate return)
* Tier 1: Match type (prefix > substring > fuzzy)
* Tier 2: Exact name bonus
* Tier 3: Type priority (method vs class based on query case)
* Tier 4: Minor bonuses (top-level, class method, name length)
*/
var SCORE_EXACT_FULL_NAME = 1000000; // Tier 0: Query exactly matches full_name
var SCORE_MATCH_PREFIX = 10000; // Tier 1: Query is prefix of name
var SCORE_MATCH_SUBSTRING = 5000; // Tier 1: Query is substring of name
var SCORE_MATCH_FUZZY = 1000; // Tier 1: Query chars appear in order
var SCORE_EXACT_NAME = 500; // Tier 2: Name exactly equals query
var SCORE_TYPE_PRIORITY = 100; // Tier 3: Preferred type (method/class)
var SCORE_TOP_LEVEL = 50; // Tier 4: Top-level over namespaced
var SCORE_CLASS_METHOD = 10; // Tier 4: Class method over instance method
/**
* Check if all characters in query appear in order in target
* e.g., "addalias" fuzzy matches "add_foo_alias"
*/
function fuzzyMatch(target, query) {
var ti = 0;
for (var qi = 0; qi < query.length; qi++) {
ti = target.indexOf(query[qi], ti);
if (ti === -1) return false;
ti++;
}
return true;
}
/**
* Parse and normalize a search query
* @param {string} query - The raw search query
* @returns {Object} Parsed query with normalized form and flags
*/
function parseQuery(query) {
// Lowercase for case-insensitive matching (so "hash" finds both Hash class and #hash methods)
var normalized = query.toLowerCase();
var isNamespaceQuery = query.includes('::');
var isMethodQuery = query.includes('#') || query.includes('.');
// Normalize . to :: (RDoc uses :: for class methods in full_name)
if (query.includes('.')) {
normalized = normalized.replace(/\./g, '::');
}
return {
original: query,
normalized: normalized,
isNamespaceQuery: isNamespaceQuery,
isMethodQuery: isMethodQuery,
// Namespace and method queries match against full_name instead of name
matchesFullName: isNamespaceQuery || isMethodQuery,
// If query starts with lowercase, prioritize methods; otherwise prioritize classes/modules/constants
prioritizeMethod: !/^[A-Z]/.test(query)
};
}
/**
* Main search function
* @param {string} query - The search query
* @param {Array} index - The search index to search in
* @returns {Array} Array of matching entries, sorted by relevance
*/
function search(query, index) {
if (!query || query.length < MIN_QUERY_LENGTH) {
return [];
}
var q = parseQuery(query);
var results = [];
for (var i = 0; i < index.length; i++) {
var entry = index[i];
var score = computeScore(entry, q);
if (score !== null) {
results.push({ entry: entry, score: score });
}
}
results.sort(function(a, b) {
return b.score - a.score;
});
return results.slice(0, MAX_RESULTS).map(function(r) {
return r.entry;
});
}
/**
* Compute the relevance score for an entry
* @param {Object} entry - The search index entry
* @param {Object} q - Parsed query from parseQuery()
* @returns {number|null} Score or null if no match
*/
function computeScore(entry, q) {
var name = entry.name;
var fullName = entry.full_name;
var type = entry.type;
var nameLower = name.toLowerCase();
var fullNameLower = fullName.toLowerCase();
// Exact full_name match (e.g., "Array#filter" matches Array#filter)
if (q.matchesFullName && fullNameLower === q.normalized) {
return SCORE_EXACT_FULL_NAME;
}
var matchScore = 0;
var target = q.matchesFullName ? fullNameLower : nameLower;
if (target.startsWith(q.normalized)) {
matchScore = SCORE_MATCH_PREFIX; // Prefix (e.g., "Arr" matches "Array")
} else if (target.includes(q.normalized)) {
matchScore = SCORE_MATCH_SUBSTRING; // Substring (e.g., "ray" matches "Array")
} else if (fuzzyMatch(target, q.normalized)) {
matchScore = SCORE_MATCH_FUZZY; // Fuzzy (e.g., "addalias" matches "add_foo_alias")
} else {
return null;
}
var score = matchScore;
var isMethod = (type === 'instance_method' || type === 'class_method');
if (q.prioritizeMethod ? isMethod : !isMethod) {
score += SCORE_TYPE_PRIORITY;
}
if (type === 'class_method') score += SCORE_CLASS_METHOD;
if (name === fullName) score += SCORE_TOP_LEVEL; // Top-level (Hash) > namespaced (Foo::Hash)
if (nameLower === q.normalized) score += SCORE_EXACT_NAME; // Exact name match
score -= name.length;
return score;
}
/**
* SearchRanker class for compatibility with the Search UI
* Provides ready() and find() interface
*/
function SearchRanker(index) {
this.index = index;
this.handlers = [];
}
SearchRanker.prototype.ready = function(fn) {
this.handlers.push(fn);
};
SearchRanker.prototype.find = function(query) {
var q = parseQuery(query);
var rawResults = search(query, this.index);
var results = rawResults.map(function(entry) {
return formatResult(entry, q);
});
var _this = this;
this.handlers.forEach(function(fn) {
fn.call(_this, results, true);
});
};
/**
* Format a search result entry for display
*/
function formatResult(entry, q) {
var result = {
title: highlightMatch(entry.full_name, q),
path: entry.path,
type: entry.type
};
if (entry.snippet) {
result.snippet = entry.snippet;
}
return result;
}
/**
* Add highlight markers (\u0001 and \u0002) to matching portions of text
* @param {string} text - The text to highlight
* @param {Object} q - Parsed query from parseQuery()
*/
function highlightMatch(text, q) {
if (!text || !q) return text;
var textLower = text.toLowerCase();
var query = q.normalized;
// Try contiguous match first (prefix or substring)
var matchIndex = textLower.indexOf(query);
if (matchIndex !== -1) {
return text.substring(0, matchIndex) +
'\u0001' + text.substring(matchIndex, matchIndex + query.length) + '\u0002' +
text.substring(matchIndex + query.length);
}
// Fall back to fuzzy highlight (highlight each matched character)
var result = '';
var ti = 0;
for (var qi = 0; qi < query.length; qi++) {
var charIndex = textLower.indexOf(query[qi], ti);
if (charIndex === -1) return text;
result += text.substring(ti, charIndex);
result += '\u0001' + text[charIndex] + '\u0002';
ti = charIndex + 1;
}
result += text.substring(ti);
return result;
}
+112
View File
@@ -0,0 +1,112 @@
(function() {
'use strict';
const STORAGE_KEY = 'rdoc-theme';
const THEME_LIGHT = 'light';
const THEME_DARK = 'dark';
/**
* Get the user's theme preference
* Priority: localStorage > system preference > light (default)
*/
function getThemePreference() {
// Check localStorage first
const stored = localStorage.getItem(STORAGE_KEY);
if (stored === THEME_LIGHT || stored === THEME_DARK) {
return stored;
}
// Check system preference
if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) {
return THEME_DARK;
}
return THEME_LIGHT;
}
/**
* Apply theme to document
*/
function applyTheme(theme) {
document.documentElement.setAttribute('data-theme', theme);
localStorage.setItem(STORAGE_KEY, theme);
// Update toggle button icon
const toggleBtn = document.getElementById('theme-toggle');
if (toggleBtn) {
const icon = toggleBtn.querySelector('.theme-toggle-icon');
if (icon) {
icon.textContent = theme === THEME_DARK ? '☀️' : '🌙';
}
toggleBtn.setAttribute('aria-label',
theme === THEME_DARK ? 'Switch to light mode' : 'Switch to dark mode'
);
}
}
/**
* Toggle between light and dark themes
*/
function toggleTheme() {
const currentTheme = document.documentElement.getAttribute('data-theme') || THEME_LIGHT;
const newTheme = currentTheme === THEME_LIGHT ? THEME_DARK : THEME_LIGHT;
applyTheme(newTheme);
// Announce to screen readers
announceThemeChange(newTheme);
}
/**
* Announce theme change to screen readers
*/
function announceThemeChange(theme) {
const announcement = document.createElement('div');
announcement.setAttribute('role', 'status');
announcement.setAttribute('aria-live', 'polite');
announcement.className = 'sr-only';
announcement.textContent = `Switched to ${theme} mode`;
document.body.appendChild(announcement);
// Remove after announcement
setTimeout(() => {
document.body.removeChild(announcement);
}, 1000);
}
/**
* Initialize theme on page load
*/
function initTheme() {
// Apply theme immediately to prevent flash
const theme = getThemePreference();
applyTheme(theme);
// Set up toggle button listener
const toggleBtn = document.getElementById('theme-toggle');
if (toggleBtn) {
toggleBtn.addEventListener('click', toggleTheme);
}
// Listen for system theme changes
if (window.matchMedia) {
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', (e) => {
// Only auto-switch if user hasn't manually set a preference
const stored = localStorage.getItem(STORAGE_KEY);
if (!stored) {
applyTheme(e.matches ? THEME_DARK : THEME_LIGHT);
}
});
}
}
// Initialize immediately (before DOMContentLoaded to prevent flash)
if (document.readyState === 'loading') {
// Apply theme as early as possible
const theme = getThemePreference();
document.documentElement.setAttribute('data-theme', theme);
document.addEventListener('DOMContentLoaded', initTheme);
} else {
initTheme();
}
})();