ligarb 0.3.0 → 0.5.0
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/assets/review.css +665 -0
- data/assets/review.js +681 -0
- data/assets/serve.js +76 -0
- data/assets/style.css +95 -0
- data/lib/ligarb/asset_manager.rb +1 -1
- data/lib/ligarb/builder.rb +176 -1
- data/lib/ligarb/chapter.rb +45 -2
- data/lib/ligarb/claude_runner.rb +185 -0
- data/lib/ligarb/cli.rb +169 -3
- data/lib/ligarb/config.rb +39 -1
- data/lib/ligarb/inotify.rb +75 -0
- data/lib/ligarb/review_store.rb +112 -0
- data/lib/ligarb/server.rb +1091 -0
- data/lib/ligarb/template.rb +4 -1
- data/lib/ligarb/version.rb +1 -1
- data/lib/ligarb/writer.rb +226 -0
- data/templates/book.html.erb +141 -13
- metadata +37 -1
data/lib/ligarb/template.rb
CHANGED
|
@@ -12,7 +12,7 @@ module Ligarb
|
|
|
12
12
|
@css_path = File.join(ASSETS_DIR, "style.css")
|
|
13
13
|
end
|
|
14
14
|
|
|
15
|
-
def render(config:, chapters:, structure:, assets:, index_entries: [])
|
|
15
|
+
def render(config:, chapters:, structure:, assets:, index_entries: [], bibliography: [])
|
|
16
16
|
css = File.read(@css_path)
|
|
17
17
|
template = File.read(@template_path)
|
|
18
18
|
|
|
@@ -31,7 +31,10 @@ module Ligarb
|
|
|
31
31
|
b.local_variable_set(:assets, assets)
|
|
32
32
|
b.local_variable_set(:repository, config.repository)
|
|
33
33
|
b.local_variable_set(:appendix_label, config.appendix_label)
|
|
34
|
+
b.local_variable_set(:ai_generated, config.ai_generated)
|
|
35
|
+
b.local_variable_set(:footer, config.effective_footer)
|
|
34
36
|
b.local_variable_set(:index_tree, build_index_tree(index_entries, chapters))
|
|
37
|
+
b.local_variable_set(:bibliography, bibliography)
|
|
35
38
|
|
|
36
39
|
ERB.new(template, trim_mode: "-").result(b)
|
|
37
40
|
end
|
data/lib/ligarb/version.rb
CHANGED
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "yaml"
|
|
4
|
+
require "json"
|
|
5
|
+
require "fileutils"
|
|
6
|
+
|
|
7
|
+
module Ligarb
|
|
8
|
+
class Writer
|
|
9
|
+
class WriterError < RuntimeError; end
|
|
10
|
+
|
|
11
|
+
BRIEF_FIELDS_FOR_BOOK_YML = %w[author output_dir chapter_numbers style repository].freeze
|
|
12
|
+
|
|
13
|
+
def initialize(brief_path, no_build: false)
|
|
14
|
+
@brief_path = File.expand_path(brief_path)
|
|
15
|
+
@no_build = no_build
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def run
|
|
19
|
+
check_claude_installed!
|
|
20
|
+
brief = load_brief
|
|
21
|
+
output_dir = output_dir_for(brief)
|
|
22
|
+
book_yml_path = File.join(output_dir, "book.yml")
|
|
23
|
+
|
|
24
|
+
if File.exist?(book_yml_path)
|
|
25
|
+
raise WriterError, "#{book_yml_path} already exists. Remove it first to regenerate."
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
FileUtils.mkdir_p(output_dir)
|
|
29
|
+
prompt = build_prompt(brief, output_dir)
|
|
30
|
+
run_claude(prompt)
|
|
31
|
+
|
|
32
|
+
unless File.exist?(book_yml_path)
|
|
33
|
+
raise WriterError, "Claude did not generate book.yml in #{output_dir}"
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
puts "Book files generated in #{output_dir}"
|
|
37
|
+
|
|
38
|
+
unless @no_build
|
|
39
|
+
puts "Building..."
|
|
40
|
+
require_relative "builder"
|
|
41
|
+
Builder.new(book_yml_path).build
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
book_yml_path
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def self.init_brief(directory = nil)
|
|
48
|
+
dir = directory || "."
|
|
49
|
+
target = File.expand_path(dir)
|
|
50
|
+
path = File.join(target, "brief.yml")
|
|
51
|
+
|
|
52
|
+
if File.exist?(path)
|
|
53
|
+
raise WriterError, "#{path} already exists."
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
FileUtils.mkdir_p(target)
|
|
57
|
+
|
|
58
|
+
File.write(path, <<~YAML)
|
|
59
|
+
# brief.yml - Book brief for ligarb write
|
|
60
|
+
title: "My Book"
|
|
61
|
+
language: ja
|
|
62
|
+
audience: ""
|
|
63
|
+
notes: |
|
|
64
|
+
5章くらいで。
|
|
65
|
+
YAML
|
|
66
|
+
|
|
67
|
+
claude_md = File.join(target, "CLAUDE.md")
|
|
68
|
+
created_claude_md = false
|
|
69
|
+
unless File.exist?(claude_md)
|
|
70
|
+
File.write(claude_md, <<~MD)
|
|
71
|
+
# ligarb book project
|
|
72
|
+
|
|
73
|
+
This is a book project using [ligarb](https://github.com/ko1/ligarb).
|
|
74
|
+
|
|
75
|
+
## Commands
|
|
76
|
+
|
|
77
|
+
- `ligarb build` — Build the book (generates build/index.html)
|
|
78
|
+
- `ligarb help` — Show full specification (Markdown syntax, config options, etc.)
|
|
79
|
+
|
|
80
|
+
## Key rules
|
|
81
|
+
|
|
82
|
+
- All chapter files are Markdown (.md), listed in book.yml
|
|
83
|
+
- The first h1 in each file is the chapter title
|
|
84
|
+
- Use ```mermaid, ```math, admonitions (> [!NOTE]), etc. as needed
|
|
85
|
+
- Run `ligarb build` after changes to verify the output
|
|
86
|
+
MD
|
|
87
|
+
created_claude_md = true
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
puts "Created #{path}"
|
|
91
|
+
puts "Created #{claude_md}" if created_claude_md
|
|
92
|
+
brief_arg = directory ? " #{path}" : ""
|
|
93
|
+
puts "Edit brief.yml, then run 'ligarb write#{brief_arg}' to generate the book."
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
private
|
|
97
|
+
|
|
98
|
+
def check_claude_installed!
|
|
99
|
+
unless system("claude", "--version", out: File::NULL, err: File::NULL)
|
|
100
|
+
raise WriterError, "'claude' command not found. Install Claude Code first."
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def load_brief
|
|
105
|
+
unless File.exist?(@brief_path)
|
|
106
|
+
raise WriterError, "#{@brief_path} not found."
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
brief = YAML.safe_load_file(@brief_path)
|
|
110
|
+
unless brief.is_a?(Hash) && brief["title"] && !brief["title"].empty?
|
|
111
|
+
raise WriterError, "'title' is required in #{@brief_path}."
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
brief
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def output_dir_for(_brief)
|
|
118
|
+
File.dirname(@brief_path)
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def build_prompt(brief, output_dir)
|
|
122
|
+
abs_output_dir = File.expand_path(output_dir)
|
|
123
|
+
spec = CLI.spec_text
|
|
124
|
+
|
|
125
|
+
lines = []
|
|
126
|
+
lines << "You are writing a book using the ligarb tool."
|
|
127
|
+
lines << ""
|
|
128
|
+
lines << "<ligarb-spec>"
|
|
129
|
+
lines << spec
|
|
130
|
+
lines << "</ligarb-spec>"
|
|
131
|
+
lines << ""
|
|
132
|
+
lines << "Write a complete book based on this brief:"
|
|
133
|
+
lines << "- Title: #{brief["title"]}"
|
|
134
|
+
lines << "- Language: #{brief["language"] || "ja"}"
|
|
135
|
+
lines << "- Target audience: #{brief["audience"]}" if brief["audience"] && !brief["audience"].empty?
|
|
136
|
+
|
|
137
|
+
if brief["notes"] && !brief["notes"].strip.empty?
|
|
138
|
+
lines << ""
|
|
139
|
+
lines << "Additional instructions:"
|
|
140
|
+
lines << brief["notes"].strip
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
book_yml_fields = BRIEF_FIELDS_FOR_BOOK_YML.select { |k| brief.key?(k) }
|
|
144
|
+
if book_yml_fields.any?
|
|
145
|
+
settings = book_yml_fields.map { |k| "#{k}: #{brief[k].inspect}" }.join(", ")
|
|
146
|
+
lines << ""
|
|
147
|
+
lines << "In book.yml, set: #{settings}"
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
sources = parse_brief_sources(brief)
|
|
151
|
+
if sources.any?
|
|
152
|
+
lines << ""
|
|
153
|
+
lines << "Reference sources (read these files for context):"
|
|
154
|
+
sources.each { |src| lines << "- #{src[:label]}: #{src[:path]}" }
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
lines << ""
|
|
158
|
+
lines << "Create all files in: #{abs_output_dir}"
|
|
159
|
+
lines << "In book.yml, always set: ai_generated: true"
|
|
160
|
+
lines << "Create book.yml first, then each chapter .md file."
|
|
161
|
+
lines << "Include a cover page for books with 4+ chapters."
|
|
162
|
+
lines << "Each chapter: substantive content with multiple ## sections."
|
|
163
|
+
lines << "Use code blocks, admonitions, mermaid diagrams where appropriate."
|
|
164
|
+
lines << "Chapter filenames: 01-topic.md, 02-topic.md, etc."
|
|
165
|
+
lines << ""
|
|
166
|
+
lines << "Create references.bib with real bibliography entries in BibTeX format."
|
|
167
|
+
lines << "In book.yml, set: bibliography: references.bib"
|
|
168
|
+
lines << "Cite references in chapter text using [text](#cite:key) syntax."
|
|
169
|
+
lines << "Search the web to find accurate bibliographic information (DOI, pages, volume, etc.)."
|
|
170
|
+
lines << "Do NOT fabricate references. Only include real, verifiable publications."
|
|
171
|
+
lines << "Use entry types: @article, @inproceedings, @book, @misc as appropriate."
|
|
172
|
+
lines << "Use UTF-8 directly for special characters (no LaTeX commands like \\c{c})."
|
|
173
|
+
lines << "BibTeX keys: authorsurname+year (e.g. knuth1984, cousot1977)."
|
|
174
|
+
|
|
175
|
+
lines.join("\n")
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def parse_brief_sources(brief)
|
|
179
|
+
base_dir = File.dirname(@brief_path)
|
|
180
|
+
(brief["sources"] || []).map do |entry|
|
|
181
|
+
case entry
|
|
182
|
+
when String
|
|
183
|
+
{ path: File.expand_path(entry, base_dir), label: File.basename(entry) }
|
|
184
|
+
when Hash
|
|
185
|
+
path = entry["path"] or raise WriterError, "source entry missing 'path'"
|
|
186
|
+
{ path: File.expand_path(path, base_dir), label: entry.fetch("label", File.basename(path)) }
|
|
187
|
+
else
|
|
188
|
+
raise WriterError, "invalid source entry: #{entry.inspect}"
|
|
189
|
+
end
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def run_claude(prompt)
|
|
194
|
+
tools = "Read,Write,Bash,WebFetch,WebSearch"
|
|
195
|
+
allowed = "Read,Write,Bash(mkdir:*),Bash(ls:*),Bash(ligarb:*),WebFetch,WebSearch"
|
|
196
|
+
cmd = ["claude", "-p", prompt, "--tools", tools, "--allowedTools", allowed,
|
|
197
|
+
"--output-format", "stream-json", "--verbose"]
|
|
198
|
+
puts "Writing with Claude... (this may take a few minutes)"
|
|
199
|
+
unparsed_lines = []
|
|
200
|
+
IO.popen(cmd, err: [:child, :out]) do |io|
|
|
201
|
+
io.each_line do |line|
|
|
202
|
+
unless parse_stream_event(line)
|
|
203
|
+
unparsed_lines << line.rstrip
|
|
204
|
+
end
|
|
205
|
+
end
|
|
206
|
+
end
|
|
207
|
+
unless $?.success?
|
|
208
|
+
msg = unparsed_lines.reject(&:empty?).last(10).join("\n")
|
|
209
|
+
raise WriterError, "Claude process failed.#{"\n#{msg}" unless msg.empty?}"
|
|
210
|
+
end
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
def parse_stream_event(line)
|
|
214
|
+
json = JSON.parse(line) rescue (return false)
|
|
215
|
+
case json["type"]
|
|
216
|
+
when "content_block_start"
|
|
217
|
+
tool = json.dig("content_block", "tool_use")
|
|
218
|
+
if tool
|
|
219
|
+
name = tool["name"]
|
|
220
|
+
puts " [#{name}]..." if name
|
|
221
|
+
end
|
|
222
|
+
end
|
|
223
|
+
true
|
|
224
|
+
end
|
|
225
|
+
end
|
|
226
|
+
end
|
data/templates/book.html.erb
CHANGED
|
@@ -4,6 +4,10 @@
|
|
|
4
4
|
<meta charset="UTF-8">
|
|
5
5
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
6
6
|
<title><%= title %></title>
|
|
7
|
+
<%- if ai_generated -%>
|
|
8
|
+
<meta name="robots" content="noindex, nofollow, noarchive">
|
|
9
|
+
<meta name="robots" content="noai, noimageai">
|
|
10
|
+
<%- end -%>
|
|
7
11
|
<style>
|
|
8
12
|
<%= css %>
|
|
9
13
|
</style>
|
|
@@ -35,6 +39,9 @@
|
|
|
35
39
|
<%- unless author.empty? -%>
|
|
36
40
|
<p class="book-author"><%= author %></p>
|
|
37
41
|
<%- end -%>
|
|
42
|
+
<%- if ai_generated -%>
|
|
43
|
+
<span class="ai-badge"><%= language == 'ja' ? 'AI 生成' : 'AI Generated' %></span>
|
|
44
|
+
<%- end -%>
|
|
38
45
|
</div>
|
|
39
46
|
<div class="search-box">
|
|
40
47
|
<input type="text" id="toc-search" placeholder="Search..." autocomplete="off">
|
|
@@ -103,6 +110,11 @@
|
|
|
103
110
|
</li>
|
|
104
111
|
<%- end -%>
|
|
105
112
|
<%- end -%>
|
|
113
|
+
<%- unless bibliography.empty? -%>
|
|
114
|
+
<li class="toc-chapter" data-chapter="__bibliography__">
|
|
115
|
+
<a href="#__bibliography__" class="toc-h1" onclick="showChapter('__bibliography__')"><%= language == 'ja' ? '参考文献' : 'Bibliography' %></a>
|
|
116
|
+
</li>
|
|
117
|
+
<%- end -%>
|
|
106
118
|
<%- unless index_tree.empty? -%>
|
|
107
119
|
<li class="toc-chapter" data-chapter="__index__">
|
|
108
120
|
<a href="#__index__" class="toc-h1" onclick="showChapter('__index__')"><%= language == 'ja' ? '索引' : 'Index' %></a>
|
|
@@ -123,7 +135,10 @@
|
|
|
123
135
|
<a href="<%= repository.chomp('/') %>/blob/HEAD/<%= chapter.relative_path %>" target="_blank" rel="noopener">View on GitHub</a>
|
|
124
136
|
</div>
|
|
125
137
|
<%- end -%>
|
|
126
|
-
<%-
|
|
138
|
+
<%- if footer && !chapter.part_title? && !chapter.cover? -%>
|
|
139
|
+
<div class="chapter-footer"><%= footer %></div>
|
|
140
|
+
<%- end -%>
|
|
141
|
+
<%- unless chapter.cover? -%>
|
|
127
142
|
<nav class="chapter-nav">
|
|
128
143
|
<%- if idx > 0 -%>
|
|
129
144
|
<a href="#" class="nav-prev" onclick="showChapter('<%= chapters[idx-1].slug %>'); return false;">← <%= chapters[idx-1].display_title %></a>
|
|
@@ -137,6 +152,16 @@
|
|
|
137
152
|
<%- end -%>
|
|
138
153
|
</section>
|
|
139
154
|
<%- end -%>
|
|
155
|
+
<%- unless bibliography.empty? -%>
|
|
156
|
+
<section class="chapter bibliography-chapter" id="chapter-__bibliography__" style="display: none;">
|
|
157
|
+
<h1><%= language == 'ja' ? '参考文献' : 'Bibliography' %></h1>
|
|
158
|
+
<ul class="bibliography-list">
|
|
159
|
+
<%- bibliography.each do |entry| -%>
|
|
160
|
+
<li id="bib-<%= entry[:key] %>"><span class="bib-label">[<%= entry[:label] %>]</span> <%= entry[:formatted_html] %></li>
|
|
161
|
+
<%- end -%>
|
|
162
|
+
</ul>
|
|
163
|
+
</section>
|
|
164
|
+
<%- end -%>
|
|
140
165
|
<%- unless index_tree.empty? -%>
|
|
141
166
|
<section class="chapter index-chapter" id="chapter-__index__" style="display: none;">
|
|
142
167
|
<h1><%= language == 'ja' ? '索引' : 'Index' %></h1>
|
|
@@ -165,10 +190,12 @@
|
|
|
165
190
|
|
|
166
191
|
<script>
|
|
167
192
|
(function() {
|
|
168
|
-
var chapters = [<%= chapters.map { |c| "\"#{c.slug}\"" }.join(", ") %><%= ', "__index__"' unless index_tree.empty? %>];
|
|
193
|
+
var chapters = [<%= chapters.map { |c| "\"#{c.slug}\"" }.join(", ") %><%= ', "__bibliography__"' unless bibliography.empty? %><%= ', "__index__"' unless index_tree.empty? %>];
|
|
169
194
|
var currentChapter = null;
|
|
170
195
|
|
|
171
|
-
|
|
196
|
+
var navigating = false; // flag to suppress pushState during popstate/initial load
|
|
197
|
+
|
|
198
|
+
function showChapter(slug, hash) {
|
|
172
199
|
chapters.forEach(function(ch) {
|
|
173
200
|
var el = document.getElementById('chapter-' + ch);
|
|
174
201
|
if (el) el.style.display = (ch === slug) ? 'block' : 'none';
|
|
@@ -185,7 +212,10 @@
|
|
|
185
212
|
});
|
|
186
213
|
|
|
187
214
|
currentChapter = slug;
|
|
188
|
-
|
|
215
|
+
var newHash = '#' + (hash || slug);
|
|
216
|
+
if (!navigating) {
|
|
217
|
+
history.pushState(null, '', newHash);
|
|
218
|
+
}
|
|
189
219
|
window.scrollTo(0, 0);
|
|
190
220
|
|
|
191
221
|
// Re-apply highlight if search is active
|
|
@@ -211,40 +241,63 @@
|
|
|
211
241
|
catch(e) { el.textContent = el.getAttribute('data-math'); }
|
|
212
242
|
}
|
|
213
243
|
});
|
|
244
|
+
section.querySelectorAll('.math-inline[data-math]').forEach(function(el) {
|
|
245
|
+
if (el.childNodes.length === 0) {
|
|
246
|
+
try { katex.render(el.getAttribute('data-math'), el, {displayMode: false, throwOnError: false}); }
|
|
247
|
+
catch(e) { el.textContent = el.getAttribute('data-math'); }
|
|
248
|
+
}
|
|
249
|
+
});
|
|
214
250
|
}
|
|
215
251
|
}
|
|
216
252
|
|
|
217
253
|
function showChapterAndScroll(slug, headingId) {
|
|
218
|
-
showChapter(slug);
|
|
219
|
-
history.replaceState(null, '', '#' + headingId);
|
|
254
|
+
showChapter(slug, headingId);
|
|
220
255
|
setTimeout(function() {
|
|
221
256
|
var target = document.getElementById(headingId);
|
|
222
257
|
if (target) target.scrollIntoView({ behavior: 'smooth' });
|
|
223
258
|
}, 50);
|
|
224
259
|
}
|
|
225
260
|
|
|
226
|
-
//
|
|
261
|
+
// Navigate to chapter/heading based on current URL hash
|
|
227
262
|
function handleHash() {
|
|
263
|
+
navigating = true;
|
|
228
264
|
var hash = location.hash.replace('#', '');
|
|
229
265
|
if (!hash) {
|
|
230
266
|
if (chapters.length > 0) showChapter(chapters[0]);
|
|
267
|
+
navigating = false;
|
|
231
268
|
return;
|
|
232
269
|
}
|
|
233
270
|
|
|
234
271
|
// Check if hash matches a chapter slug directly
|
|
235
272
|
if (chapters.indexOf(hash) !== -1) {
|
|
236
273
|
showChapter(hash);
|
|
274
|
+
navigating = false;
|
|
275
|
+
return;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
<%- unless bibliography.empty? -%>
|
|
279
|
+
// Check for bibliography entry (bib-KEY)
|
|
280
|
+
var bibMatch = hash.match(/^bib-(.+)/);
|
|
281
|
+
if (bibMatch) {
|
|
282
|
+
showChapter('__bibliography__', hash);
|
|
283
|
+
setTimeout(function() {
|
|
284
|
+
var target = document.getElementById(hash);
|
|
285
|
+
if (target) target.scrollIntoView({ behavior: 'smooth' });
|
|
286
|
+
}, 50);
|
|
287
|
+
navigating = false;
|
|
237
288
|
return;
|
|
238
289
|
}
|
|
290
|
+
<%- end -%>
|
|
239
291
|
|
|
240
292
|
// Check for footnote links (fn: or fnref:)
|
|
241
293
|
var fnMatch = hash.match(/^(?:fn|fnref):(.+?)--/);
|
|
242
294
|
if (fnMatch) {
|
|
243
295
|
var fnChSlug = fnMatch[1];
|
|
244
296
|
if (chapters.indexOf(fnChSlug) !== -1) {
|
|
245
|
-
if (currentChapter !== fnChSlug) showChapter(fnChSlug);
|
|
297
|
+
if (currentChapter !== fnChSlug) showChapter(fnChSlug, hash);
|
|
246
298
|
var fnTarget = document.getElementById(hash);
|
|
247
299
|
if (fnTarget) fnTarget.scrollIntoView({ behavior: 'smooth' });
|
|
300
|
+
navigating = false;
|
|
248
301
|
return;
|
|
249
302
|
}
|
|
250
303
|
}
|
|
@@ -255,26 +308,98 @@
|
|
|
255
308
|
var chSlug = parts[0];
|
|
256
309
|
if (chapters.indexOf(chSlug) !== -1) {
|
|
257
310
|
showChapterAndScroll(chSlug, hash);
|
|
311
|
+
navigating = false;
|
|
258
312
|
return;
|
|
259
313
|
}
|
|
260
314
|
}
|
|
261
315
|
|
|
262
316
|
// Fallback
|
|
263
317
|
if (chapters.length > 0) showChapter(chapters[0]);
|
|
318
|
+
navigating = false;
|
|
264
319
|
}
|
|
265
320
|
|
|
266
321
|
// TOC search + content highlight
|
|
267
322
|
var searchInput = document.getElementById('toc-search');
|
|
268
323
|
var clearBtn = document.getElementById('search-clear');
|
|
269
324
|
|
|
325
|
+
// Build text cache for full-text search (lazy, built on first search)
|
|
326
|
+
var chapterTextCache = null;
|
|
327
|
+
function buildTextCache() {
|
|
328
|
+
if (chapterTextCache) return;
|
|
329
|
+
chapterTextCache = {};
|
|
330
|
+
chapters.forEach(function(slug) {
|
|
331
|
+
var section = document.getElementById('chapter-' + slug);
|
|
332
|
+
if (section) {
|
|
333
|
+
chapterTextCache[slug] = section.textContent.toLowerCase();
|
|
334
|
+
}
|
|
335
|
+
});
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
function countMatches(text, query) {
|
|
339
|
+
var count = 0;
|
|
340
|
+
var idx = text.indexOf(query);
|
|
341
|
+
while (idx !== -1) {
|
|
342
|
+
count++;
|
|
343
|
+
idx = text.indexOf(query, idx + query.length);
|
|
344
|
+
}
|
|
345
|
+
return count;
|
|
346
|
+
}
|
|
347
|
+
|
|
270
348
|
function updateSearch() {
|
|
271
349
|
var query = searchInput.value.toLowerCase();
|
|
350
|
+
clearBtn.style.display = query ? 'block' : 'none';
|
|
351
|
+
|
|
352
|
+
// Remove existing match count badges
|
|
353
|
+
document.querySelectorAll('.search-match-count').forEach(function(el) { el.remove(); });
|
|
354
|
+
|
|
272
355
|
var items = document.querySelectorAll('.toc-chapter');
|
|
356
|
+
var partItems = document.querySelectorAll('.toc-part, .toc-appendix');
|
|
357
|
+
|
|
358
|
+
if (!query || query.length < 2) {
|
|
359
|
+
// Show all items when query is too short
|
|
360
|
+
items.forEach(function(item) { item.style.display = ''; });
|
|
361
|
+
partItems.forEach(function(item) { item.style.display = ''; });
|
|
362
|
+
highlightContent('');
|
|
363
|
+
return;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
buildTextCache();
|
|
367
|
+
|
|
368
|
+
// Track which chapters match (content or TOC text)
|
|
369
|
+
var matchingSlugs = {};
|
|
273
370
|
items.forEach(function(item) {
|
|
274
|
-
var
|
|
275
|
-
|
|
371
|
+
var slug = item.dataset.chapter;
|
|
372
|
+
var tocText = item.textContent.toLowerCase();
|
|
373
|
+
var contentText = chapterTextCache[slug] || '';
|
|
374
|
+
var tocMatch = tocText.indexOf(query) !== -1;
|
|
375
|
+
var contentMatch = contentText.indexOf(query) !== -1;
|
|
376
|
+
|
|
377
|
+
if (tocMatch || contentMatch) {
|
|
378
|
+
item.style.display = '';
|
|
379
|
+
matchingSlugs[slug] = true;
|
|
380
|
+
// Show match count badge for content matches
|
|
381
|
+
if (contentMatch) {
|
|
382
|
+
var count = countMatches(contentText, query);
|
|
383
|
+
var badge = document.createElement('span');
|
|
384
|
+
badge.className = 'search-match-count';
|
|
385
|
+
badge.textContent = count;
|
|
386
|
+
var link = item.querySelector('a');
|
|
387
|
+
if (link) link.appendChild(badge);
|
|
388
|
+
}
|
|
389
|
+
} else {
|
|
390
|
+
item.style.display = 'none';
|
|
391
|
+
}
|
|
276
392
|
});
|
|
277
|
-
|
|
393
|
+
|
|
394
|
+
// Show/hide part and appendix headers based on whether they have visible children
|
|
395
|
+
partItems.forEach(function(item) {
|
|
396
|
+
var children = item.querySelectorAll('.toc-chapter');
|
|
397
|
+
var hasVisible = Array.prototype.some.call(children, function(ch) {
|
|
398
|
+
return ch.style.display !== 'none';
|
|
399
|
+
});
|
|
400
|
+
item.style.display = hasVisible ? '' : 'none';
|
|
401
|
+
});
|
|
402
|
+
|
|
278
403
|
highlightContent(searchInput.value);
|
|
279
404
|
}
|
|
280
405
|
|
|
@@ -394,9 +519,12 @@
|
|
|
394
519
|
window.showChapterAndScroll = showChapterAndScroll;
|
|
395
520
|
window.renderSpecialBlocks = function() { if (currentChapter) renderSpecialBlocks(currentChapter); };
|
|
396
521
|
|
|
397
|
-
// Initialize
|
|
522
|
+
// Initialize (replace initial entry so first back goes to previous page, not same page)
|
|
523
|
+
navigating = true;
|
|
398
524
|
handleHash();
|
|
399
|
-
|
|
525
|
+
history.replaceState(null, '', location.hash || '#' + (chapters[0] || ''));
|
|
526
|
+
navigating = false;
|
|
527
|
+
window.addEventListener('popstate', handleHash);
|
|
400
528
|
})();
|
|
401
529
|
</script>
|
|
402
530
|
<%- if assets.need?(:highlight) -%>
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: ligarb
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.5.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- ligarb contributors
|
|
@@ -37,6 +37,34 @@ dependencies:
|
|
|
37
37
|
- - "~>"
|
|
38
38
|
- !ruby/object:Gem::Version
|
|
39
39
|
version: '1.1'
|
|
40
|
+
- !ruby/object:Gem::Dependency
|
|
41
|
+
name: webrick
|
|
42
|
+
requirement: !ruby/object:Gem::Requirement
|
|
43
|
+
requirements:
|
|
44
|
+
- - ">="
|
|
45
|
+
- !ruby/object:Gem::Version
|
|
46
|
+
version: '1.7'
|
|
47
|
+
type: :runtime
|
|
48
|
+
prerelease: false
|
|
49
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
50
|
+
requirements:
|
|
51
|
+
- - ">="
|
|
52
|
+
- !ruby/object:Gem::Version
|
|
53
|
+
version: '1.7'
|
|
54
|
+
- !ruby/object:Gem::Dependency
|
|
55
|
+
name: fiddle
|
|
56
|
+
requirement: !ruby/object:Gem::Requirement
|
|
57
|
+
requirements:
|
|
58
|
+
- - ">="
|
|
59
|
+
- !ruby/object:Gem::Version
|
|
60
|
+
version: '1.1'
|
|
61
|
+
type: :runtime
|
|
62
|
+
prerelease: false
|
|
63
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
64
|
+
requirements:
|
|
65
|
+
- - ">="
|
|
66
|
+
- !ruby/object:Gem::Version
|
|
67
|
+
version: '1.1'
|
|
40
68
|
- !ruby/object:Gem::Dependency
|
|
41
69
|
name: rake
|
|
42
70
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -72,16 +100,24 @@ executables:
|
|
|
72
100
|
extensions: []
|
|
73
101
|
extra_rdoc_files: []
|
|
74
102
|
files:
|
|
103
|
+
- assets/review.css
|
|
104
|
+
- assets/review.js
|
|
105
|
+
- assets/serve.js
|
|
75
106
|
- assets/style.css
|
|
76
107
|
- exe/ligarb
|
|
77
108
|
- lib/ligarb/asset_manager.rb
|
|
78
109
|
- lib/ligarb/builder.rb
|
|
79
110
|
- lib/ligarb/chapter.rb
|
|
111
|
+
- lib/ligarb/claude_runner.rb
|
|
80
112
|
- lib/ligarb/cli.rb
|
|
81
113
|
- lib/ligarb/config.rb
|
|
82
114
|
- lib/ligarb/initializer.rb
|
|
115
|
+
- lib/ligarb/inotify.rb
|
|
116
|
+
- lib/ligarb/review_store.rb
|
|
117
|
+
- lib/ligarb/server.rb
|
|
83
118
|
- lib/ligarb/template.rb
|
|
84
119
|
- lib/ligarb/version.rb
|
|
120
|
+
- lib/ligarb/writer.rb
|
|
85
121
|
- templates/book.html.erb
|
|
86
122
|
homepage: https://github.com/ligarb/ligarb
|
|
87
123
|
licenses:
|