Skip to content
Open
5 changes: 5 additions & 0 deletions lib/rdoc/generator/template/aliki/_head.rhtml
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,11 @@
defer
></script>

<script
src="<%= h asset_rel_prefix %>/js/navigation.js?v=<%= h VERSION %>"
defer
></script>

<script
src="<%= h asset_rel_prefix %>/js/search_ranker.js?v=<%= h VERSION %>"
defer
Expand Down
2 changes: 1 addition & 1 deletion lib/rdoc/generator/template/aliki/_sidebar_classes.rhtml
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,6 @@
</span>
</summary>

<%= generate_class_index_content(@classes, rel_prefix) %>

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Will this method still be needed?

@skatkov skatkov Sep 30, 2026 •

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This method is still used in the Darkfish generator.

The Darkfish generator has same problem as well as Aliki. But because Darkfish is already deprecated, I assume there is no need to do similar changes to Darkfish.

<ul id="namespace-navigation" class="link-list nav-list"></ul>
</details>
</div>
70 changes: 70 additions & 0 deletions lib/rdoc/generator/template/aliki/js/navigation.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
'use strict';

// Reuse the search index's visible classes and modules, adding unlinked
// ancestors where a namespace is hidden but has visible descendants.
function buildClassTree(index) {
const roots = [];
const byName = new Map();

index.forEach((entry) => {
if (entry.type !== 'class' && entry.type !== 'module') return;

let children = roots;
let fullName = '';
entry.full_name.split('::').forEach((name) => {
fullName = fullName ? fullName + '::' + name : name;
let node = byName.get(fullName);
if (!node) {
node = {name, path: null, children: []};
byName.set(fullName, node);
children.push(node);
}
children = node.children;
});
byName.get(fullName).path = entry.path;
});

return roots;
}

// Share the class tree across pages without embedding it in every page's HTML.
function buildClassNavigation(container, nodes, prefix) {
function renderBranch(entries, list, expandSingleRoot = false) {
list.className = 'link-list nav-list';

entries.forEach((node) => {
const item = document.createElement('li');
const code = document.createElement('code');
if (node.path) {
const link = document.createElement('a');
link.href = prefix + node.path;
link.textContent = node.name;
code.appendChild(link);
} else {
code.textContent = node.name;
}

if (node.children.length) {
const details = document.createElement('details');
const summary = document.createElement('summary');
summary.appendChild(code);
details.appendChild(summary);
details.appendChild(renderBranch(node.children, document.createElement('ul')));
details.open = expandSingleRoot;
item.appendChild(details);
} else {
item.appendChild(code);
}
list.appendChild(item);
});
return list;
}

renderBranch(nodes, container, nodes.length === 1);
}

document.addEventListener('DOMContentLoaded', () => {
const container = document.getElementById('namespace-navigation');
if (!container || typeof search_data === 'undefined') return;
buildClassNavigation(container, buildClassTree(search_data.index), index_rel_prefix);
});
32 changes: 32 additions & 0 deletions test/rdoc/generator/aliki_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@ def test_write_style_sheet_copies_css_and_js_only
# Aliki should have these assets
assert_file 'css/rdoc.css'
assert_file 'js/aliki.js'
assert_file 'js/navigation.js'
assert_file 'js/search_controller.js'
assert_file 'js/search_navigation.js'
assert_file 'js/search_ranker.js'
Expand Down Expand Up @@ -241,6 +242,37 @@ def test_generate
assert_match %r{<main role="main">}, index
end

def test_shared_class_navigation
@klass.add_class RDoc::NormalClass, 'Inner'
@g.generate

data = JSON.parse(File.read('js/search_data.js').delete_prefix('var search_data = ').delete_suffix(';'))['index']
assert_include data.map { |entry| entry['full_name'] }, 'Klass::Inner'

page = File.read('Klass/Inner.html')
assert_include page, '<ul id="namespace-navigation" class="link-list nav-list"></ul>'
assert_include page, '../js/search_data.js?v='
sidebar = page[/<div id="classindex-section".*?<\/nav>/m]
assert_not_include sidebar, '<a href="../Klass.html">Klass</a>'
end

def test_navigation_preserves_hidden_namespace_and_omits_hidden_leaf
@klass = @top_level.add_class RDoc::NormalClass, 'HiddenRoot'
inner = @klass.add_class RDoc::NormalClass, 'Inner'
leaf = inner.add_class RDoc::NormalClass, 'Leaf'
hidden = @klass.add_class RDoc::NormalClass, 'Hidden'
hidden.document_self = false
inner.document_self = false
@klass.document_self = false

data = @g.build_search_index
names = data.map { |entry| entry[:full_name] }
assert_include names, leaf.full_name
assert_not_include names, @klass.full_name
assert_not_include names, inner.full_name
assert_not_include names, hidden.full_name
end

def test_canonical_url
@klass.add_class RDoc::NormalClass, 'Inner'
@store.options.canonical_root = @options.canonical_root = "https://example.com/docs/"
Expand Down
95 changes: 60 additions & 35 deletions test/rdoc/rdoc_server_test.rb
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
# frozen_string_literal: true
require_relative 'support/test_case'
require 'net/http'

class RDocServerTest < RDoc::TestCase

Expand Down Expand Up @@ -79,52 +80,44 @@ def test_route_returns_404_for_missing_page
end

def test_check_for_changes_parses_and_reloads_rbs_signatures
@server.instance_variable_set(:@file_mtimes, @rdoc.last_modified.keys.to_h { |file|
[file, File.mtime(file)]
})
with_running_server do |port|
sig_dir = File.join @dir, 'sig'
FileUtils.mkdir_p sig_dir
File.write File.join(sig_dir, 'example.rbs'), <<~RBS
class Example
# RBS method docs.
def greet: () -> String
end
RBS

sig_dir = File.join @dir, 'sig'
FileUtils.mkdir_p sig_dir
File.write File.join(sig_dir, 'example.rbs'), <<~RBS
class Example
# RBS method docs.
def greet: () -> String
wait_for('class page to include the RBS method documentation') do
get(port, '/Example.html').body.include?('RBS method docs.')
end
RBS

_out, err = capture_output do
assert @server.send(:check_for_changes)
example = @rdoc.store.find_class_or_module 'Example'
greet = example.find_method 'greet', false
assert_equal "RBS method docs.", greet.comment.to_s.strip
assert_equal ['() -> String'], greet.type_signature_lines
assert_equal ['() -> String'], @rdoc.store.rbs_signature_for(greet)
end

assert_not_include err, 'Error parsing'

example = @rdoc.store.find_class_or_module 'Example'
greet = example.find_method 'greet', false
assert_equal "RBS method docs.", greet.comment.to_s.strip
assert_equal ['() -> String'], greet.type_signature_lines
assert_equal ['() -> String'], @rdoc.store.rbs_signature_for(greet)
end

def test_check_for_changes_parses_rbs_sources
@server.instance_variable_set(:@file_mtimes, @rdoc.last_modified.keys.to_h { |file|
[file, File.mtime(file)]
})
with_running_server do |port|
File.write File.join(@dir, 'sample.rbs'), <<~RBS
class Sample
def greet: () -> String
end
RBS

File.write File.join(@dir, 'sample.rbs'), <<~RBS
class Sample
def greet: () -> String
wait_for('class page to include the new RBS source') do
get(port, '/Sample.html').code == '200'
end
RBS

_out, err = capture_output do
assert @server.send(:check_for_changes)
sample = @rdoc.store.find_class_or_module 'Sample'
greet = sample.find_method 'greet', false
assert_equal ['() -> String'], greet.type_signature_lines
end

assert_not_include err, 'Error parsing'

sample = @rdoc.store.find_class_or_module 'Sample'
greet = sample.find_method 'greet', false
assert_equal ['() -> String'], greet.type_signature_lines
end

def test_current_watch_files_deduplicates_symlinked_source_tree
Expand All @@ -140,4 +133,36 @@ def test_current_watch_files_deduplicates_symlinked_source_tree
rescue NotImplementedError, Errno::EACCES, Errno::EPERM
omit 'symlinks are not supported'
end

private

def with_running_server
port = TCPServer.open('127.0.0.1', 0) { |socket| socket.addr[1] }
@server = RDoc::Server.new(@rdoc, port)
server_thread = Thread.new { @server.start }

wait_for('server to start') do
get(port, '/__status').code == '200'
rescue Errno::ECONNREFUSED
false
end

yield port
ensure
server_thread&.raise(Interrupt) if server_thread&.alive?
server_thread&.join
end

def get(port, path)
Net::HTTP.start('127.0.0.1', port) { |http| http.get(path) }
end

def wait_for(description)
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + 10

until yield
flunk "Timed out waiting for #{description}" if Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline
sleep 0.05
end
end
end
Loading