ligarb 0.1.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.
data/lib/ligarb/cli.rb ADDED
@@ -0,0 +1,341 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "version"
4
+ require_relative "builder"
5
+ require_relative "initializer"
6
+
7
+ module Ligarb
8
+ module CLI
9
+ module_function
10
+
11
+ def run(args)
12
+ command = args.shift
13
+
14
+ case command
15
+ when "build"
16
+ config_path = args.first || "book.yml"
17
+ Builder.new(config_path).build
18
+ when "init"
19
+ Initializer.new(args.first).run
20
+ when "--help", "-h", nil
21
+ print_usage
22
+ when "help"
23
+ print_spec
24
+ when "version", "--version", "-v"
25
+ puts "ligarb #{VERSION}"
26
+ else
27
+ $stderr.puts "Unknown command: #{command}"
28
+ $stderr.puts "Run 'ligarb --help' for usage information."
29
+ exit 1
30
+ end
31
+ end
32
+
33
+ def print_usage
34
+ puts <<~USAGE
35
+ ligarb #{VERSION} - Generate a single-page HTML book from Markdown files
36
+
37
+ Usage:
38
+ ligarb init [DIRECTORY] Create a new book project
39
+ ligarb build [CONFIG] Build the HTML book (default CONFIG: book.yml)
40
+ ligarb help Show detailed specification (for AI integration)
41
+ ligarb version Show version number
42
+
43
+ Options:
44
+ -h, --help Show this usage summary
45
+ -v, --version Show version number
46
+
47
+ Configuration (book.yml):
48
+ title (required) Book title
49
+ chapters (required) Book structure (chapters, parts, appendix)
50
+ author (optional) Author name (default: "")
51
+ language (optional) HTML lang attribute (default: "en")
52
+ output_dir (optional) Output directory (default: "build")
53
+ chapter_numbers (optional) Show chapter/section numbers (default: true)
54
+ style (optional) Custom CSS file path (default: none)
55
+ repository (optional) GitHub repository URL for "Edit on GitHub" links
56
+
57
+ Example:
58
+ ligarb build
59
+ ligarb build path/to/book.yml
60
+ USAGE
61
+ end
62
+
63
+ def print_spec
64
+ puts <<~SPEC
65
+ ligarb - Generate a single-page HTML book from Markdown files
66
+
67
+ Version: #{VERSION}
68
+
69
+ == Overview ==
70
+
71
+ ligarb converts multiple Markdown files into a self-contained index.html.
72
+ The generated HTML includes:
73
+ - A left sidebar with a searchable table of contents (h1-h3)
74
+ - Chapter-based content switching in the main area
75
+ - Permalink support via URL hash (#chapter-slug)
76
+ - Responsive design with print-friendly styles
77
+ - Syntax-highlighted code blocks
78
+ - Search with content highlighting
79
+ - Chapter and section numbering (configurable)
80
+ - Previous/Next chapter navigation
81
+ - Dark mode toggle (saved to localStorage)
82
+ - Custom CSS support
83
+ - "Edit on GitHub" links (optional)
84
+ - Footnotes (kramdown syntax)
85
+
86
+ == Commands ==
87
+
88
+ ligarb init [DIRECTORY] Create a new book project with scaffolding.
89
+ If DIRECTORY is given, creates and populates that directory.
90
+ If omitted, populates the current directory.
91
+ Generates book.yml, 01-introduction.md, and images/.
92
+ If .md files already exist, registers them as chapters.
93
+ Aborts if book.yml already exists.
94
+
95
+ ligarb build [CONFIG] Build the HTML book.
96
+ CONFIG defaults to 'book.yml' in the current directory.
97
+
98
+ ligarb help Show this detailed specification.
99
+
100
+ ligarb --help Show short usage summary.
101
+
102
+ ligarb version Show the version number.
103
+
104
+ == Configuration: book.yml ==
105
+
106
+ The configuration file is a YAML file with the following fields:
107
+
108
+ title: (required) The book title displayed in the header and <title> tag.
109
+ author: (optional) Author name displayed in the header. Default: empty.
110
+ language: (optional) HTML lang attribute value. Default: "en".
111
+ output_dir: (optional) Output directory relative to book.yml. Default: "build".
112
+ chapter_numbers: (optional) Show chapter/section numbers (e.g. "1.", "1.1", "1.1.1").
113
+ Default: true.
114
+ style: (optional) Path to a custom CSS file relative to book.yml.
115
+ Loaded after the default styles, so it can override any rule.
116
+ repository: (optional) GitHub repository URL (e.g. "https://github.com/user/repo").
117
+ When set, each chapter shows a "View on GitHub" link.
118
+ The link points to {repository}/blob/HEAD/{path-from-git-root}.
119
+ The chapter path is resolved relative to the Git repository root.
120
+ chapters: (required) Book structure. An array that can contain:
121
+ - A cover: a centered title/landing page
122
+ - A string: a chapter Markdown file path (relative to book.yml)
123
+ - A part: groups chapters under a titled section
124
+ - An appendix: groups chapters with alphabetic numbering (A, B, C, ...)
125
+
126
+ The chapters array supports four element types:
127
+
128
+ 1. Cover (object with 'cover' key):
129
+ chapters:
130
+ - cover: cover.md # Markdown file: displayed as centered title page
131
+ # Not shown in the TOC sidebar.
132
+
133
+ 2. Plain chapter (string):
134
+ chapters:
135
+ - 01-introduction.md
136
+
137
+ 3. Part (object with 'part' and 'chapters' keys):
138
+ chapters:
139
+ - part: part1.md # Markdown file: h1 = part title, body = opening text
140
+ chapters:
141
+ - 01-introduction.md
142
+ - 02-getting-started.md
143
+
144
+ 4. Appendix (object with 'appendix' key, value is array of chapter files):
145
+ chapters:
146
+ - appendix:
147
+ - a1-references.md
148
+ - a2-glossary.md
149
+
150
+ These can be combined freely:
151
+
152
+ chapters:
153
+ - cover: cover.md
154
+ - part: part1.md
155
+ chapters:
156
+ - 01-introduction.md
157
+ - 02-getting-started.md
158
+ - part: part2.md
159
+ chapters:
160
+ - 03-advanced.md
161
+ - appendix:
162
+ - a1-references.md
163
+
164
+ Part numbering is sequential across parts (1, 2, 3, ...).
165
+ Appendix numbering uses letters (A, B, C, ...).
166
+
167
+ Example book.yml (simple):
168
+
169
+ title: "My Software Guide"
170
+ author: "Author Name"
171
+ language: "ja"
172
+ chapters:
173
+ - 01-introduction.md
174
+ - 02-getting-started.md
175
+ - 03-advanced.md
176
+
177
+ Example book.yml (with parts and appendix):
178
+
179
+ title: "My Software Guide"
180
+ author: "Author Name"
181
+ language: "ja"
182
+ chapters:
183
+ - part: part1.md
184
+ chapters:
185
+ - 01-introduction.md
186
+ - 02-getting-started.md
187
+ - part: part2.md
188
+ chapters:
189
+ - 03-advanced.md
190
+ - 04-deployment.md
191
+ - appendix:
192
+ - a1-config-reference.md
193
+
194
+ == Directory Structure ==
195
+
196
+ A typical book project has this structure:
197
+
198
+ my-book/
199
+ ├── book.yml # Configuration file
200
+ ├── part1.md # Part opening page (optional)
201
+ ├── 01-introduction.md # Markdown source files
202
+ ├── 02-getting-started.md
203
+ ├── 03-advanced.md
204
+ └── images/ # Image files (optional)
205
+ ├── screenshot.png
206
+ └── diagram.svg
207
+
208
+ After running 'ligarb build', the output is:
209
+
210
+ my-book/
211
+ └── build/
212
+ ├── index.html # Single-page HTML book
213
+ ├── js/ # Auto-downloaded (only if needed)
214
+ ├── css/ # Auto-downloaded (only if needed)
215
+ └── images/ # Copied image files
216
+
217
+ == Markdown Files ==
218
+
219
+ Each Markdown file represents one chapter. ligarb uses GitHub Flavored
220
+ Markdown (GFM) via kramdown. Supported syntax includes:
221
+
222
+ - Headings (# h1, ## h2, ### h3) — used for TOC generation
223
+ - Code blocks with language-specific syntax highlighting (``` fenced blocks)
224
+ - Tables, task lists, strikethrough, and other GFM extensions
225
+ - Inline HTML
226
+
227
+ The first heading (h1) in each file becomes the chapter title in the TOC.
228
+
229
+ == Fenced Code Blocks ==
230
+
231
+ The following fenced code block types are automatically detected and
232
+ rendered. Required JS/CSS is auto-downloaded on first build to build/js/
233
+ and build/css/.
234
+
235
+ ```ruby, ```python, etc. Syntax highlighting (highlight.js, BSD-3-Clause)
236
+ ```mermaid Diagrams: flowcharts, sequence, class, etc.
237
+ (mermaid, MIT)
238
+ ```math LaTeX math equations (KaTeX, MIT)
239
+
240
+ These are rendered visually in the output HTML — use them freely.
241
+
242
+ Mermaid example (flowchart):
243
+
244
+ ```mermaid
245
+ graph TD
246
+ A[Start] --> B{Check}
247
+ B -->|Yes| C[OK]
248
+ B -->|No| D[Retry]
249
+ ```
250
+
251
+ Mermaid example (sequence diagram):
252
+
253
+ ```mermaid
254
+ sequenceDiagram
255
+ Client->>Server: Request
256
+ Server-->>Client: Response
257
+ ```
258
+
259
+ Math example (KaTeX, LaTeX syntax):
260
+
261
+ ```math
262
+ E = mc^2
263
+ ```
264
+
265
+ == Images ==
266
+
267
+ Place image files in the 'images/' directory next to book.yml.
268
+ Reference them from Markdown with relative paths:
269
+
270
+ ![Screenshot](images/screenshot.png)
271
+
272
+ ligarb rewrites image paths to 'images/filename' in the output and copies
273
+ all files from the images/ directory to the output.
274
+
275
+ == Build ==
276
+
277
+ Run from the directory containing book.yml:
278
+
279
+ ligarb build
280
+
281
+ Or specify a path to book.yml:
282
+
283
+ ligarb build path/to/book.yml
284
+
285
+ The generated index.html is a fully self-contained HTML file (CSS and JS
286
+ are embedded). Open it directly in a browser — no web server needed.
287
+
288
+ == Footnotes ==
289
+
290
+ Footnotes use kramdown syntax:
291
+
292
+ This is a sentence with a footnote[^1].
293
+
294
+ [^1]: This is the footnote content.
295
+
296
+ Footnote IDs are scoped per chapter to avoid collisions in the single-page
297
+ output.
298
+
299
+ == Custom CSS ==
300
+
301
+ Add a 'style' field to book.yml to inject custom CSS:
302
+
303
+ style: "custom.css"
304
+
305
+ The custom CSS is loaded after the default styles. You can override any
306
+ CSS custom property (e.g. colors, fonts, sidebar width) or add new rules.
307
+
308
+ Example custom.css:
309
+
310
+ :root {
311
+ --color-accent: #e63946;
312
+ --sidebar-width: 320px;
313
+ }
314
+
315
+ == Dark Mode ==
316
+
317
+ The generated HTML includes a dark mode toggle button (moon icon) in the
318
+ sidebar header. The user's preference is saved to localStorage and persists
319
+ across page reloads.
320
+
321
+ Custom CSS can override dark mode colors using the [data-theme="dark"]
322
+ selector.
323
+
324
+ == Edit on GitHub ==
325
+
326
+ Add a 'repository' field to book.yml:
327
+
328
+ repository: "https://github.com/user/repo"
329
+
330
+ Each chapter will show a "View on GitHub" link pointing to:
331
+ {repository}/blob/HEAD/{path-from-git-root}
332
+
333
+ == Previous/Next Navigation ==
334
+
335
+ Each chapter displays Previous and Next navigation links at the bottom.
336
+ These follow the flat chapter order (including across parts and appendix).
337
+ Part title pages do not show navigation.
338
+ SPEC
339
+ end
340
+ end
341
+ end
@@ -0,0 +1,118 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+
5
+ module Ligarb
6
+ class Config
7
+ REQUIRED_KEYS = %w[title chapters].freeze
8
+
9
+ # Represents a structural entry in the book
10
+ StructEntry = Struct.new(:type, :path, :children, keyword_init: true)
11
+ # type: :chapter, :part, or :appendix_group
12
+ # path: markdown file path (for :chapter and :part), nil for :appendix_group
13
+ # children: array of StructEntry (for :part and :appendix_group)
14
+
15
+ attr_reader :title, :author, :language, :output_dir, :base_dir,
16
+ :chapter_numbers, :structure, :style, :repository
17
+
18
+ def initialize(path)
19
+ @base_dir = File.dirname(File.expand_path(path))
20
+ data = YAML.safe_load_file(path)
21
+
22
+ validate!(data)
23
+
24
+ @title = data["title"]
25
+ @author = data.fetch("author", "")
26
+ @language = data.fetch("language", "en")
27
+ @output_dir = data.fetch("output_dir", "build")
28
+ @chapter_numbers = data.fetch("chapter_numbers", true)
29
+ @style = data.fetch("style", nil)
30
+ @repository = data.fetch("repository", nil)
31
+ @structure = parse_structure(data["chapters"])
32
+ end
33
+
34
+ def output_path
35
+ File.join(@base_dir, @output_dir)
36
+ end
37
+
38
+ def style_path
39
+ @style ? File.join(@base_dir, @style) : nil
40
+ end
41
+
42
+ def appendix_label
43
+ @language == "ja" ? "付録" : "Appendix"
44
+ end
45
+
46
+ # Returns a flat list of all chapter file paths (excluding part title pages)
47
+ def chapter_paths
48
+ collect_chapter_paths(@structure)
49
+ end
50
+
51
+ # Returns all file paths including part title pages
52
+ def all_file_paths
53
+ collect_all_paths(@structure)
54
+ end
55
+
56
+ private
57
+
58
+ def parse_structure(entries)
59
+ entries.map do |entry|
60
+ case entry
61
+ when String
62
+ StructEntry.new(type: :chapter, path: File.join(@base_dir, entry))
63
+ when Hash
64
+ if entry.key?("part")
65
+ children = (entry["chapters"] || []).map do |ch|
66
+ StructEntry.new(type: :chapter, path: File.join(@base_dir, ch))
67
+ end
68
+ StructEntry.new(type: :part, path: File.join(@base_dir, entry["part"]), children: children)
69
+ elsif entry.key?("cover")
70
+ StructEntry.new(type: :cover, path: File.join(@base_dir, entry["cover"]))
71
+ elsif entry.key?("appendix")
72
+ children = entry["appendix"].map do |ch|
73
+ StructEntry.new(type: :chapter, path: File.join(@base_dir, ch))
74
+ end
75
+ StructEntry.new(type: :appendix_group, path: nil, children: children)
76
+ else
77
+ abort "Error: unknown entry type in chapters: #{entry.inspect}"
78
+ end
79
+ else
80
+ abort "Error: invalid entry in chapters: #{entry.inspect}"
81
+ end
82
+ end
83
+ end
84
+
85
+ def collect_chapter_paths(entries)
86
+ entries.flat_map do |entry|
87
+ case entry.type
88
+ when :chapter, :cover
89
+ [entry.path]
90
+ when :part
91
+ [entry.path] + collect_chapter_paths(entry.children || [])
92
+ when :appendix_group
93
+ collect_chapter_paths(entry.children || [])
94
+ end
95
+ end
96
+ end
97
+
98
+ def collect_all_paths(entries)
99
+ collect_chapter_paths(entries)
100
+ end
101
+
102
+ def validate!(data)
103
+ unless data.is_a?(Hash)
104
+ abort "Error: book.yml must be a YAML mapping"
105
+ end
106
+
107
+ REQUIRED_KEYS.each do |key|
108
+ unless data.key?(key)
109
+ abort "Error: book.yml is missing required key '#{key}'"
110
+ end
111
+ end
112
+
113
+ unless data["chapters"].is_a?(Array) && !data["chapters"].empty?
114
+ abort "Error: 'chapters' must be a non-empty array"
115
+ end
116
+ end
117
+ end
118
+ end
@@ -0,0 +1,97 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "yaml"
5
+
6
+ module Ligarb
7
+ class Initializer
8
+ def initialize(directory = nil)
9
+ @directory = directory || "."
10
+ end
11
+
12
+ def run
13
+ target = File.expand_path(@directory)
14
+ book_yml = File.join(target, "book.yml")
15
+
16
+ if File.exist?(book_yml)
17
+ $stderr.puts "Error: book.yml already exists in #{target}"
18
+ exit 1
19
+ end
20
+
21
+ FileUtils.mkdir_p(target)
22
+ FileUtils.mkdir_p(File.join(target, "images"))
23
+
24
+ title = dir_to_title(File.basename(File.expand_path(target)))
25
+
26
+ existing_md = collect_markdown_files(target)
27
+ if existing_md.any?
28
+ chapter_paths = existing_md
29
+ else
30
+ chapter_paths = ["01-introduction.md"]
31
+ File.write(File.join(target, "01-introduction.md"), generate_chapter)
32
+ end
33
+
34
+ File.write(book_yml, generate_book_yml(title, chapter_paths))
35
+ File.write(File.join(target, "images", ".gitkeep"), "")
36
+
37
+ print_success(target, chapter_paths)
38
+ end
39
+
40
+ private
41
+
42
+ def dir_to_title(dirname)
43
+ dirname.gsub(/[-_]/, " ").gsub(/\b\w/, &:upcase)
44
+ end
45
+
46
+ def collect_markdown_files(target)
47
+ Dir.glob("*.md", base: target).sort
48
+ end
49
+
50
+ def generate_book_yml(title, chapter_paths)
51
+ {
52
+ "title" => title,
53
+ "author" => "",
54
+ "language" => "en",
55
+ "output_dir" => "build",
56
+ "chapters" => chapter_paths,
57
+ }.to_yaml
58
+ end
59
+
60
+ def generate_chapter
61
+ <<~MARKDOWN
62
+ # Introduction
63
+
64
+ Welcome to your new book.
65
+ MARKDOWN
66
+ end
67
+
68
+ def print_success(target, chapter_paths)
69
+ rel = relative_path(target)
70
+ name = File.basename(File.expand_path(target))
71
+
72
+ puts "Created new book project in #{rel}"
73
+ puts
74
+ puts " #{name}/"
75
+ puts " ├── book.yml"
76
+ chapter_paths.each_with_index do |path, i|
77
+ prefix = i == chapter_paths.size - 1 && !File.exist?(File.join(target, "images", ".gitkeep")) ? "└──" : "├──"
78
+ puts " #{prefix} #{path}"
79
+ end
80
+ puts " └── images/"
81
+ puts
82
+ puts "Next steps:"
83
+ puts " cd #{rel}" if @directory && @directory != "."
84
+ puts " Edit book.yml to set your book title and author"
85
+ puts " Add Markdown files and list them in book.yml"
86
+ puts " Run 'ligarb build' to generate HTML"
87
+ end
88
+
89
+ def relative_path(target)
90
+ if @directory && @directory != "."
91
+ @directory.start_with?("/") ? @directory : "./#{@directory}"
92
+ else
93
+ "."
94
+ end
95
+ end
96
+ end
97
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "erb"
4
+
5
+ module Ligarb
6
+ class Template
7
+ TEMPLATE_DIR = File.expand_path("../../templates", __dir__)
8
+ ASSETS_DIR = File.expand_path("../../assets", __dir__)
9
+
10
+ def initialize
11
+ @template_path = File.join(TEMPLATE_DIR, "book.html.erb")
12
+ @css_path = File.join(ASSETS_DIR, "style.css")
13
+ end
14
+
15
+ def render(config:, chapters:, structure:, assets:)
16
+ css = File.read(@css_path)
17
+ template = File.read(@template_path)
18
+
19
+ custom_css = if config.style_path && File.exist?(config.style_path)
20
+ File.read(config.style_path)
21
+ end
22
+
23
+ b = binding
24
+ b.local_variable_set(:title, config.title)
25
+ b.local_variable_set(:author, config.author)
26
+ b.local_variable_set(:language, config.language)
27
+ b.local_variable_set(:chapters, chapters)
28
+ b.local_variable_set(:structure, structure)
29
+ b.local_variable_set(:css, css)
30
+ b.local_variable_set(:custom_css, custom_css)
31
+ b.local_variable_set(:assets, assets)
32
+ b.local_variable_set(:repository, config.repository)
33
+ b.local_variable_set(:appendix_label, config.appendix_label)
34
+
35
+ ERB.new(template, trim_mode: "-").result(b)
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ligarb
4
+ VERSION = "0.1.0"
5
+ end