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.
@@ -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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Ligarb
4
- VERSION = "0.3.0"
4
+ VERSION = "0.5.0"
5
5
  end
@@ -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
@@ -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
- <%- unless chapter.part_title? || chapter.cover? -%>
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;">&larr; <%= 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
- function showChapter(slug) {
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
- history.replaceState(null, '', '#' + slug);
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
- // Handle initial hash
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 text = item.textContent.toLowerCase();
275
- item.style.display = text.indexOf(query) !== -1 ? '' : 'none';
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
- clearBtn.style.display = query ? 'block' : 'none';
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
- window.addEventListener('hashchange', handleHash);
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.3.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: