Skip to content

Commit cfd4b71

Browse files
piti6claude
andcommitted
feat: add minimal modern web layout with search and sub-section navigation
- Restyle web output with clean typography, sidebar, and responsive layout - Add book cover image and title to sidebar header - Add collapsible sub-section navigation (h2 headings) per chapter - Add client-side full-text search with pre-built JSON index - Scroll-based active section highlighting in sidebar - Custom layout-web.html.erb to override Re:VIEW default template Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 2487f5c commit cfd4b71

4 files changed

Lines changed: 563 additions & 42 deletions

File tree

Lines changed: 205 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,205 @@
1+
<?xml version="1.0" encoding="UTF-8"?>
2+
<!DOCTYPE html>
3+
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:epub="http://www.idpf.org/2007/ops" xml:lang="<%=h @language %>">
4+
<head>
5+
<meta charset="UTF-8" />
6+
<% if @javascripts.present? %>
7+
<% @javascripts.each do |js| %>
8+
<%= js %>
9+
10+
<% end %>
11+
<% end %>
12+
<% if @stylesheets.present? %>
13+
<% @stylesheets.each do |style| %>
14+
<link rel="stylesheet" type="text/css" href="<%=h style %>" />
15+
<% end %>
16+
<% end%>
17+
<% if @next.present? %><link rel="next" title="<%= h(@next_title)%>" href="<%= h(@next.id.to_s+"."+@book.config['htmlext']) %>" /><% end %>
18+
<% if @prev.present? %><link rel="prev" title="<%= h(@prev_title)%>" href="<%= h(@prev.id.to_s+"."+@book.config['htmlext']) %>" /><% end %>
19+
<meta name="generator" content="Re:VIEW" />
20+
<title><%= @title %> | <%=h @book.config.name_of("booktitle")%></title>
21+
</head>
22+
<body<%= @body_ext %>>
23+
<div class="book">
24+
<nav class="side-content">
25+
<div class="side-header">
26+
<% if @book.config["coverimage"].present? %>
27+
<a href="index.html"><img class="side-cover" src="<%=h @book.config["imagedir"] %>/<%=h @book.config["coverimage"] %>" alt="<%=h @book.config.name_of("booktitle") %>" /></a>
28+
<% end %>
29+
<h1 class="side-title"><%=h @book.config.name_of("booktitle") %></h1>
30+
</div>
31+
<div class="search-box">
32+
<input type="text" id="search-input" placeholder="Search..." autocomplete="off" />
33+
<div id="search-results" class="search-results"></div>
34+
</div>
35+
<%= @toc %>
36+
<p class="review-signature">powered by <a href="http://reviewml.org/">Re:VIEW</a></p>
37+
</nav>
38+
<div class="book-body">
39+
<header>
40+
</header>
41+
<div class="book-page">
42+
<%= @body %>
43+
</div>
44+
<nav class="book-navi book-prev">
45+
<% if @prev.present? %>
46+
<a href="<%= h(@prev.id.to_s+"."+@book.config['htmlext']) %>">
47+
<div class="book-cursor"><span class="cursor-prev">&#9664;</span></div>
48+
</a>
49+
<% end %>
50+
</nav>
51+
<nav class="book-navi book-next">
52+
<% if @next.present? %>
53+
<a href="<%= h(@next.id.to_s+"."+@book.config['htmlext']) %>">
54+
<div class="book-cursor"><span class="cursor-next">&#9654;</span></div>
55+
</a>
56+
<% end %>
57+
</nav>
58+
</div>
59+
</div>
60+
<footer>
61+
<% if @book.config["copyright"].present? %>
62+
<p class="copyright"><%=h @config["copyright"] %></p>
63+
<% end %>
64+
</footer>
65+
<script>
66+
(function() {
67+
var currentPage = location.pathname.split('/').pop().replace(/\.html$/, '');
68+
var tocItems = document.querySelectorAll('.book-toc > li');
69+
var headings = document.querySelectorAll('.book-page h2');
70+
71+
function matchPage(href) {
72+
return href.replace(/\.html$/, '') === currentPage;
73+
}
74+
75+
tocItems.forEach(function(li) {
76+
var link = li.querySelector('a');
77+
if (!link) return;
78+
var href = link.getAttribute('href');
79+
80+
// Highlight current page
81+
if (matchPage(href)) {
82+
li.classList.add('toc-active');
83+
}
84+
85+
// Add sub-sections for current page
86+
if (matchPage(href) && headings.length > 0) {
87+
var subUl = document.createElement('ul');
88+
subUl.className = 'toc-sub';
89+
headings.forEach(function(h2) {
90+
var id = h2.getAttribute('id');
91+
var text = h2.textContent.replace(/^\s+/, '');
92+
var subLi = document.createElement('li');
93+
var subA = document.createElement('a');
94+
subA.href = '#' + id;
95+
subA.textContent = text;
96+
subLi.appendChild(subA);
97+
subUl.appendChild(subLi);
98+
});
99+
li.appendChild(subUl);
100+
}
101+
});
102+
103+
// Scroll-based highlight for sub-sections
104+
if (headings.length > 0) {
105+
var subLinks = document.querySelectorAll('.toc-sub a');
106+
function updateActive() {
107+
var scrollY = window.scrollY || document.documentElement.scrollTop;
108+
var active = null;
109+
headings.forEach(function(h2, i) {
110+
if (h2.getBoundingClientRect().top + window.scrollY - 80 <= scrollY) {
111+
active = i;
112+
}
113+
});
114+
subLinks.forEach(function(a, i) {
115+
a.parentElement.classList.toggle('toc-sub-active', i === active);
116+
});
117+
}
118+
window.addEventListener('scroll', updateActive);
119+
updateActive();
120+
}
121+
})();
122+
123+
// --- Search ---
124+
(function() {
125+
var input = document.getElementById('search-input');
126+
var resultsDiv = document.getElementById('search-results');
127+
var searchIndex = null;
128+
129+
input.addEventListener('focus', function() {
130+
if (searchIndex) return;
131+
fetch('search-index.json')
132+
.then(function(r) { return r.json(); })
133+
.then(function(data) { searchIndex = data; })
134+
.catch(function() { searchIndex = []; });
135+
});
136+
137+
input.addEventListener('input', function() {
138+
var q = input.value.trim().toLowerCase();
139+
if (!q || !searchIndex) {
140+
resultsDiv.innerHTML = '';
141+
resultsDiv.style.display = 'none';
142+
return;
143+
}
144+
var keywords = q.split(/\s+/);
145+
var matches = searchIndex.filter(function(entry) {
146+
var haystack = (entry.title + ' ' + entry.text).toLowerCase();
147+
return keywords.every(function(kw) { return haystack.indexOf(kw) !== -1; });
148+
}).slice(0, 15);
149+
150+
if (matches.length === 0) {
151+
resultsDiv.innerHTML = '<div class="search-empty">No results</div>';
152+
} else {
153+
resultsDiv.innerHTML = matches.map(function(m) {
154+
var href = m.id ? m.file + '#' + m.id : m.file;
155+
var snippet = highlightSnippet(m.text, keywords);
156+
return '<a class="search-result" href="' + href + '">'
157+
+ '<div class="search-result-title">' + escapeHtml(m.title) + '</div>'
158+
+ '<div class="search-result-snippet">' + snippet + '</div>'
159+
+ '</a>';
160+
}).join('');
161+
}
162+
resultsDiv.style.display = 'block';
163+
});
164+
165+
// Close results on outside click
166+
document.addEventListener('click', function(e) {
167+
if (!e.target.closest('.search-box')) {
168+
resultsDiv.style.display = 'none';
169+
}
170+
});
171+
172+
// Close on Escape
173+
input.addEventListener('keydown', function(e) {
174+
if (e.key === 'Escape') {
175+
resultsDiv.style.display = 'none';
176+
input.blur();
177+
}
178+
});
179+
180+
function escapeHtml(s) {
181+
return s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
182+
}
183+
184+
function highlightSnippet(text, keywords) {
185+
// Find the first keyword match position and show context around it
186+
var lower = text.toLowerCase();
187+
var pos = -1;
188+
for (var i = 0; i < keywords.length; i++) {
189+
var p = lower.indexOf(keywords[i]);
190+
if (p !== -1 && (pos === -1 || p < pos)) pos = p;
191+
}
192+
var start = Math.max(0, pos - 30);
193+
var end = Math.min(text.length, start + 120);
194+
var snippet = (start > 0 ? '...' : '') + text.slice(start, end) + (end < text.length ? '...' : '');
195+
snippet = escapeHtml(snippet);
196+
keywords.forEach(function(kw) {
197+
var re = new RegExp('(' + kw.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + ')', 'gi');
198+
snippet = snippet.replace(re, '<mark>$1</mark>');
199+
});
200+
return snippet;
201+
}
202+
})();
203+
</script>
204+
</body>
205+
</html>

0 commit comments

Comments
 (0)