ligarb 0.3.0 → 0.4.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 20b29dff31e22937e2ab71a7011d1ed2fb193c8717753c7a23500767645807f1
4
- data.tar.gz: be6404f8dccb7cea765b8635f9663b57f0c23e73253a29da6ac49ca0017baae1
3
+ metadata.gz: ab265966761408e494e47dbc0e00d188d86d99d881be30d5055af5d2abe29ef0
4
+ data.tar.gz: ecca92d4ca8f601b2c24be43694b92b5f56b37e1787f3c84540e37f2d111551f
5
5
  SHA512:
6
- metadata.gz: 9ca9e973840571772b22b493ddfbc15b87c844abf05e84c2805c3d63ede347a3e245745e5bab2b8d2a3cb8d7b13b3c6c9d94f70b2d5c6d572eefadc11a839d64
7
- data.tar.gz: 98f30f871f31b96331ebac03a4e0bb85690e612e14dd0412592ff2a1400a897698807d206f851143561e27bd384d32273517531708c2f1af7ee59aaaedf33c17
6
+ metadata.gz: 9c9be72721b4753bcd00ac2bd5125bc7e4c5361f8c4773ca0c7183a4a0ca5ae80a663d82ac231a0e5b64d70e79f16f7af9de3367a857207d840cb7cf4f5efb6a
7
+ data.tar.gz: 70a5189e3b2758b9a1c145ec34502a6402ec7b72f79454f7ed1bf56cfc0881d38a9f56fffbb7b5d96259b0e18151a5cf2dcaf4649ea97f1bb6ff0f49e0636c7d
data/assets/style.css CHANGED
@@ -670,6 +670,34 @@ mark.search-highlight {
670
670
  text-decoration: underline;
671
671
  }
672
672
 
673
+ /* === AI Generated Notice === */
674
+ .ai-badge {
675
+ display: inline-block;
676
+ font-size: 0.7rem;
677
+ font-weight: 600;
678
+ color: #ca8a04;
679
+ background: #fefce8;
680
+ border: 1px solid #facc15;
681
+ border-radius: 3px;
682
+ padding: 0.1rem 0.4rem;
683
+ margin-top: 0.3rem;
684
+ letter-spacing: 0.03em;
685
+ }
686
+
687
+ [data-theme="dark"] .ai-badge {
688
+ color: #fbbf24;
689
+ background: #2e2a1a;
690
+ border-color: #854d0e;
691
+ }
692
+
693
+ .chapter-footer {
694
+ margin-top: 2rem;
695
+ padding: 0.5rem 0.75rem;
696
+ font-size: 0.8rem;
697
+ color: var(--color-text-muted);
698
+ border-top: 1px solid var(--color-border);
699
+ }
700
+
673
701
  /* === Print === */
674
702
  @media print {
675
703
  .sidebar,
@@ -22,7 +22,7 @@ module Ligarb
22
22
  },
23
23
  },
24
24
  katex: {
25
- fence_pattern: /class="math-block"/,
25
+ fence_pattern: /class="math-(block|inline)"/,
26
26
  files: {
27
27
  "js/katex.min.js" => "https://cdn.jsdelivr.net/npm/katex@0.16/dist/katex.min.js",
28
28
  "css/katex.min.css" => "https://cdn.jsdelivr.net/npm/katex@0.16/dist/katex.min.css",
@@ -101,6 +101,7 @@ module Ligarb
101
101
  @html = rewrite_image_paths(doc.to_html)
102
102
  @html = apply_heading_ids(@html)
103
103
  @html = convert_special_code_blocks(@html)
104
+ @html = convert_inline_math(@html)
104
105
  @html = convert_admonitions(@html)
105
106
  @html = scope_footnote_ids(@html)
106
107
  @index_entries = []
@@ -172,6 +173,24 @@ module Ligarb
172
173
  end
173
174
  end
174
175
 
176
+ def convert_inline_math(html)
177
+ # Protect <pre>...</pre> and <code>...</code> from conversion
178
+ placeholders = []
179
+ protected = html.gsub(%r{<(pre|code)([ >])(.*?)</\1>}m) do
180
+ placeholders << $&
181
+ "\x00PROTECT#{placeholders.size - 1}\x00"
182
+ end
183
+
184
+ # Convert $...$ to inline math (exclude $$, and $ followed/preceded by space)
185
+ result = protected.gsub(/(?<!\$)\$(?!\$)(?!\s)(.+?)(?<!\s)(?<!\$)\$(?!\$)/m) do
186
+ raw = decode_entities($1)
187
+ %(<span class="math-inline" data-math="#{encode_attr(raw)}"></span>)
188
+ end
189
+
190
+ # Restore protected parts
191
+ result.gsub(/\x00PROTECT(\d+)\x00/) { placeholders[$1.to_i] }
192
+ end
193
+
175
194
  def decode_entities(text)
176
195
  text.gsub("&amp;", "&").gsub("&lt;", "<").gsub("&gt;", ">").gsub("&quot;", '"').gsub("&#39;", "'")
177
196
  end
data/lib/ligarb/cli.rb CHANGED
@@ -17,6 +17,16 @@ module Ligarb
17
17
  Builder.new(config_path).build
18
18
  when "init"
19
19
  Initializer.new(args.first).run
20
+ when "write"
21
+ if args.delete("--init")
22
+ require_relative "writer"
23
+ Writer.init_brief(args.first)
24
+ else
25
+ brief_path = args.reject { |a| a.start_with?("--") }.first || "brief.yml"
26
+ no_build = args.include?("--no-build")
27
+ require_relative "writer"
28
+ Writer.new(brief_path, no_build: no_build).run
29
+ end
20
30
  when "--help", "-h", nil
21
31
  print_usage
22
32
  when "help"
@@ -37,6 +47,8 @@ module Ligarb
37
47
  Usage:
38
48
  ligarb init [DIRECTORY] Create a new book project
39
49
  ligarb build [CONFIG] Build the HTML book (default CONFIG: book.yml)
50
+ ligarb write [BRIEF] Generate a book with AI from brief.yml
51
+ ligarb write --init [DIR] Create DIR/brief.yml template
40
52
  ligarb help Show detailed specification (for AI integration)
41
53
  ligarb version Show version number
42
54
 
@@ -53,6 +65,8 @@ module Ligarb
53
65
  chapter_numbers (optional) Show chapter/section numbers (default: true)
54
66
  style (optional) Custom CSS file path (default: none)
55
67
  repository (optional) GitHub repository URL for "Edit on GitHub" links
68
+ ai_generated (optional) Mark as AI-generated (badge + meta tags, default: false)
69
+ footer (optional) Custom text at bottom of each chapter
56
70
 
57
71
  Example:
58
72
  ligarb build
@@ -60,8 +74,8 @@ module Ligarb
60
74
  USAGE
61
75
  end
62
76
 
63
- def print_spec
64
- puts <<~SPEC
77
+ def spec_text
78
+ <<~SPEC
65
79
  ligarb - Generate a single-page HTML book from Markdown files
66
80
 
67
81
  Version: #{VERSION}
@@ -117,6 +131,14 @@ module Ligarb
117
131
  When set, each chapter shows a "View on GitHub" link.
118
132
  The link points to {repository}/blob/HEAD/{path-from-git-root}.
119
133
  The chapter path is resolved relative to the Git repository root.
134
+ ai_generated: (optional) Mark the book as AI-generated content. Default: false.
135
+ When true: adds an "AI Generated" badge in the sidebar header,
136
+ adds a default disclaimer footer to each chapter, and adds
137
+ noindex/noai meta tags to prevent search indexing and AI training.
138
+ The footer text can be overridden with the 'footer' field.
139
+ footer: (optional) Custom text displayed at the bottom of each chapter.
140
+ Overrides the default ai_generated disclaimer if both are set.
141
+ Useful for copyright notices, disclaimers, or other per-chapter text.
120
142
  chapters: (required) Book structure. An array that can contain:
121
143
  - A cover: a centered title/landing page
122
144
  - A string: a chapter Markdown file path (relative to book.yml)
@@ -262,6 +284,17 @@ module Ligarb
262
284
  E = mc^2
263
285
  ```
264
286
 
287
+ Inline math uses $...$ syntax within text:
288
+
289
+ The equation $E = mc^2$ is well-known.
290
+
291
+ Rules for inline math:
292
+ - $$ is not matched (use ```math for display math)
293
+ - $ followed by a space is not matched (e.g. $10)
294
+ - $ preceded by a space is not matched
295
+ - Content inside <code> and <pre> is not affected
296
+ - The content is rendered with KaTeX (displayMode: false)
297
+
265
298
  == Images ==
266
299
 
267
300
  Place image files in the 'images/' directory next to book.yml.
@@ -405,8 +438,43 @@ module Ligarb
405
438
 
406
439
  Each chapter displays Previous and Next navigation links at the bottom.
407
440
  These follow the flat chapter order (including across parts and appendix).
408
- Part title pages do not show navigation.
441
+ Cover pages do not show navigation.
442
+
443
+ == Write Command ==
444
+
445
+ ligarb write [BRIEF] Generate a complete book using AI (Claude).
446
+ BRIEF defaults to 'brief.yml' in the current directory.
447
+ Reads the brief, sends a prompt to Claude, and builds
448
+ the generated book. Files are created in the same
449
+ directory as brief.yml.
450
+
451
+ ligarb write --init [DIR] Create a brief.yml template.
452
+ If DIR is given, creates DIR/brief.yml (mkdir as needed).
453
+ If omitted, creates brief.yml in the current directory.
454
+
455
+ ligarb write --no-build Generate files only, skip the build step.
456
+
457
+ brief.yml fields:
458
+
459
+ title: (required) The book title.
460
+ language: (optional) Language. Default: "ja".
461
+ audience: (optional) Target audience (used in the prompt).
462
+ notes: (optional) Additional instructions for Claude (free text).
463
+ author: (optional) Passed through to book.yml.
464
+ output_dir: (optional) Passed through to book.yml.
465
+ chapter_numbers: (optional) Passed through to book.yml.
466
+ style: (optional) Passed through to book.yml.
467
+ repository: (optional) Passed through to book.yml.
468
+
469
+ The book is generated in the directory containing brief.yml.
470
+ Example: 'ligarb write ruby_book/brief.yml' creates files in ruby_book/.
471
+
472
+ Requires the 'claude' CLI to be installed.
409
473
  SPEC
410
474
  end
475
+
476
+ def print_spec
477
+ puts spec_text
478
+ end
411
479
  end
412
480
  end
data/lib/ligarb/config.rb CHANGED
@@ -13,7 +13,8 @@ module Ligarb
13
13
  # children: array of StructEntry (for :part and :appendix_group)
14
14
 
15
15
  attr_reader :title, :author, :language, :output_dir, :base_dir,
16
- :chapter_numbers, :structure, :style, :repository
16
+ :chapter_numbers, :structure, :style, :repository,
17
+ :ai_generated, :footer
17
18
 
18
19
  def initialize(path)
19
20
  @base_dir = File.dirname(File.expand_path(path))
@@ -28,6 +29,8 @@ module Ligarb
28
29
  @chapter_numbers = data.fetch("chapter_numbers", true)
29
30
  @style = data.fetch("style", nil)
30
31
  @repository = data.fetch("repository", nil)
32
+ @ai_generated = data.fetch("ai_generated", false)
33
+ @footer = data.fetch("footer", nil)
31
34
  @structure = parse_structure(data["chapters"])
32
35
  end
33
36
 
@@ -43,6 +46,17 @@ module Ligarb
43
46
  @language == "ja" ? "付録" : "Appendix"
44
47
  end
45
48
 
49
+ def effective_footer
50
+ return @footer if @footer
51
+ return nil unless @ai_generated
52
+
53
+ if @language == "ja"
54
+ "この章の内容は AI によって生成されました。正確性は保証されません。"
55
+ else
56
+ "This chapter was generated by AI. Accuracy is not guaranteed."
57
+ end
58
+ end
59
+
46
60
  # Returns a flat list of all chapter file paths (excluding part title pages)
47
61
  def chapter_paths
48
62
  collect_chapter_paths(@structure)
@@ -31,6 +31,8 @@ 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))
35
37
 
36
38
  ERB.new(template, trim_mode: "-").result(b)
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Ligarb
4
- VERSION = "0.3.0"
4
+ VERSION = "0.4.0"
5
5
  end
@@ -0,0 +1,173 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+ require "fileutils"
5
+
6
+ module Ligarb
7
+ class Writer
8
+ BRIEF_FIELDS_FOR_BOOK_YML = %w[author output_dir chapter_numbers style repository].freeze
9
+
10
+ def initialize(brief_path, no_build: false)
11
+ @brief_path = File.expand_path(brief_path)
12
+ @no_build = no_build
13
+ end
14
+
15
+ def run
16
+ check_claude_installed!
17
+ brief = load_brief
18
+ output_dir = output_dir_for(brief)
19
+ book_yml_path = File.join(output_dir, "book.yml")
20
+
21
+ if File.exist?(book_yml_path)
22
+ $stderr.puts "Error: #{book_yml_path} already exists. Remove it first to regenerate."
23
+ exit 1
24
+ end
25
+
26
+ FileUtils.mkdir_p(output_dir)
27
+ prompt = build_prompt(brief, output_dir)
28
+ run_claude(prompt)
29
+
30
+ unless File.exist?(book_yml_path)
31
+ $stderr.puts "Error: Claude did not generate book.yml in #{output_dir}"
32
+ exit 1
33
+ end
34
+
35
+ puts "Book files generated in #{output_dir}"
36
+
37
+ unless @no_build
38
+ puts "Building..."
39
+ require_relative "builder"
40
+ Builder.new(book_yml_path).build
41
+ end
42
+ end
43
+
44
+ def self.init_brief(directory = nil)
45
+ dir = directory || "."
46
+ target = File.expand_path(dir)
47
+ path = File.join(target, "brief.yml")
48
+
49
+ if File.exist?(path)
50
+ $stderr.puts "Error: #{path} already exists."
51
+ exit 1
52
+ end
53
+
54
+ FileUtils.mkdir_p(target)
55
+
56
+ File.write(path, <<~YAML)
57
+ # brief.yml - Book brief for ligarb write
58
+ title: "My Book"
59
+ language: ja
60
+ audience: ""
61
+ notes: |
62
+ 5章くらいで。
63
+ YAML
64
+
65
+ claude_md = File.join(target, "CLAUDE.md")
66
+ created_claude_md = false
67
+ unless File.exist?(claude_md)
68
+ File.write(claude_md, <<~MD)
69
+ # ligarb book project
70
+
71
+ This is a book project using [ligarb](https://github.com/ko1/ligarb).
72
+
73
+ ## Commands
74
+
75
+ - `ligarb build` — Build the book (generates build/index.html)
76
+ - `ligarb help` — Show full specification (Markdown syntax, config options, etc.)
77
+
78
+ ## Key rules
79
+
80
+ - All chapter files are Markdown (.md), listed in book.yml
81
+ - The first h1 in each file is the chapter title
82
+ - Use ```mermaid, ```math, admonitions (> [!NOTE]), etc. as needed
83
+ - Run `ligarb build` after changes to verify the output
84
+ MD
85
+ created_claude_md = true
86
+ end
87
+
88
+ puts "Created #{path}"
89
+ puts "Created #{claude_md}" if created_claude_md
90
+ brief_arg = directory ? " #{path}" : ""
91
+ puts "Edit brief.yml, then run 'ligarb write#{brief_arg}' to generate the book."
92
+ end
93
+
94
+ private
95
+
96
+ def check_claude_installed!
97
+ unless system("claude", "--version", out: File::NULL, err: File::NULL)
98
+ $stderr.puts "Error: 'claude' command not found. Install Claude Code first."
99
+ exit 1
100
+ end
101
+ end
102
+
103
+ def load_brief
104
+ unless File.exist?(@brief_path)
105
+ $stderr.puts "Error: #{@brief_path} not found."
106
+ exit 1
107
+ end
108
+
109
+ brief = YAML.safe_load_file(@brief_path)
110
+ unless brief.is_a?(Hash) && brief["title"] && !brief["title"].empty?
111
+ $stderr.puts "Error: 'title' is required in #{@brief_path}."
112
+ exit 1
113
+ end
114
+
115
+ brief
116
+ end
117
+
118
+ def output_dir_for(_brief)
119
+ File.dirname(@brief_path)
120
+ end
121
+
122
+ def build_prompt(brief, output_dir)
123
+ abs_output_dir = File.expand_path(output_dir)
124
+ spec = CLI.spec_text
125
+
126
+ lines = []
127
+ lines << "You are writing a book using the ligarb tool."
128
+ lines << ""
129
+ lines << "<ligarb-spec>"
130
+ lines << spec
131
+ lines << "</ligarb-spec>"
132
+ lines << ""
133
+ lines << "Write a complete book based on this brief:"
134
+ lines << "- Title: #{brief["title"]}"
135
+ lines << "- Language: #{brief["language"] || "ja"}"
136
+ lines << "- Target audience: #{brief["audience"]}" if brief["audience"] && !brief["audience"].empty?
137
+
138
+ if brief["notes"] && !brief["notes"].strip.empty?
139
+ lines << ""
140
+ lines << "Additional instructions:"
141
+ lines << brief["notes"].strip
142
+ end
143
+
144
+ book_yml_fields = BRIEF_FIELDS_FOR_BOOK_YML.select { |k| brief.key?(k) }
145
+ if book_yml_fields.any?
146
+ settings = book_yml_fields.map { |k| "#{k}: #{brief[k].inspect}" }.join(", ")
147
+ lines << ""
148
+ lines << "In book.yml, set: #{settings}"
149
+ end
150
+
151
+ lines << ""
152
+ lines << "Create all files in: #{abs_output_dir}"
153
+ lines << "In book.yml, always set: ai_generated: true"
154
+ lines << "Create book.yml first, then each chapter .md file."
155
+ lines << "Include a cover page for books with 4+ chapters."
156
+ lines << "Each chapter: substantive content with multiple ## sections."
157
+ lines << "Use code blocks, admonitions, mermaid diagrams where appropriate."
158
+ lines << "Chapter filenames: 01-topic.md, 02-topic.md, etc."
159
+
160
+ lines.join("\n")
161
+ end
162
+
163
+ def run_claude(prompt)
164
+ tools = "Write,Bash,WebFetch,WebSearch"
165
+ allowed = "Write,Bash(mkdir:*),Bash(ls:*),Bash(ligarb:*),WebFetch,WebSearch"
166
+ cmd = ["claude", "-p", "--verbose", prompt, "--tools", tools, "--allowedTools", allowed]
167
+ unless system(*cmd)
168
+ $stderr.puts "Error: Claude process failed."
169
+ exit 1
170
+ end
171
+ end
172
+ end
173
+ 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">
@@ -123,7 +130,10 @@
123
130
  <a href="<%= repository.chomp('/') %>/blob/HEAD/<%= chapter.relative_path %>" target="_blank" rel="noopener">View on GitHub</a>
124
131
  </div>
125
132
  <%- end -%>
126
- <%- unless chapter.part_title? || chapter.cover? -%>
133
+ <%- if footer && !chapter.part_title? && !chapter.cover? -%>
134
+ <div class="chapter-footer"><%= footer %></div>
135
+ <%- end -%>
136
+ <%- unless chapter.cover? -%>
127
137
  <nav class="chapter-nav">
128
138
  <%- if idx > 0 -%>
129
139
  <a href="#" class="nav-prev" onclick="showChapter('<%= chapters[idx-1].slug %>'); return false;">&larr; <%= chapters[idx-1].display_title %></a>
@@ -211,6 +221,12 @@
211
221
  catch(e) { el.textContent = el.getAttribute('data-math'); }
212
222
  }
213
223
  });
224
+ section.querySelectorAll('.math-inline[data-math]').forEach(function(el) {
225
+ if (el.childNodes.length === 0) {
226
+ try { katex.render(el.getAttribute('data-math'), el, {displayMode: false, throwOnError: false}); }
227
+ catch(e) { el.textContent = el.getAttribute('data-math'); }
228
+ }
229
+ });
214
230
  }
215
231
  }
216
232
 
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.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - ligarb contributors
@@ -82,6 +82,7 @@ files:
82
82
  - lib/ligarb/initializer.rb
83
83
  - lib/ligarb/template.rb
84
84
  - lib/ligarb/version.rb
85
+ - lib/ligarb/writer.rb
85
86
  - templates/book.html.erb
86
87
  homepage: https://github.com/ligarb/ligarb
87
88
  licenses: