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.
- checksums.yaml +7 -0
- data/assets/style.css +592 -0
- data/exe/ligarb +6 -0
- data/lib/ligarb/asset_manager.rb +90 -0
- data/lib/ligarb/builder.rb +135 -0
- data/lib/ligarb/chapter.rb +246 -0
- data/lib/ligarb/cli.rb +341 -0
- data/lib/ligarb/config.rb +118 -0
- data/lib/ligarb/initializer.rb +97 -0
- data/lib/ligarb/template.rb +38 -0
- data/lib/ligarb/version.rb +5 -0
- data/templates/book.html.erb +335 -0
- metadata +107 -0
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require_relative "config"
|
|
5
|
+
require_relative "chapter"
|
|
6
|
+
require_relative "template"
|
|
7
|
+
require_relative "asset_manager"
|
|
8
|
+
|
|
9
|
+
module Ligarb
|
|
10
|
+
class Builder
|
|
11
|
+
def initialize(config_path)
|
|
12
|
+
@config = Config.new(config_path)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def build
|
|
16
|
+
structure = load_structure
|
|
17
|
+
|
|
18
|
+
all_chapters = collect_all_chapters(structure)
|
|
19
|
+
assign_relative_paths(all_chapters) if @config.repository
|
|
20
|
+
|
|
21
|
+
assets = AssetManager.new(@config.output_path)
|
|
22
|
+
assets.detect(all_chapters)
|
|
23
|
+
assets.provision!
|
|
24
|
+
|
|
25
|
+
html = Template.new.render(config: @config, chapters: all_chapters,
|
|
26
|
+
structure: structure, assets: assets)
|
|
27
|
+
|
|
28
|
+
FileUtils.mkdir_p(@config.output_path)
|
|
29
|
+
output_file = File.join(@config.output_path, "index.html")
|
|
30
|
+
File.write(output_file, html)
|
|
31
|
+
|
|
32
|
+
copy_images
|
|
33
|
+
|
|
34
|
+
puts "Built #{output_file}"
|
|
35
|
+
puts " #{all_chapters.size} chapter(s)"
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
# StructNode mirrors Config::StructEntry but holds loaded Chapter objects
|
|
41
|
+
StructNode = Struct.new(:type, :chapter, :children, keyword_init: true)
|
|
42
|
+
|
|
43
|
+
def load_structure
|
|
44
|
+
chapter_num = 0
|
|
45
|
+
appendix_num = 0
|
|
46
|
+
|
|
47
|
+
@config.structure.map do |entry|
|
|
48
|
+
case entry.type
|
|
49
|
+
when :cover
|
|
50
|
+
ch = load_chapter(entry.path)
|
|
51
|
+
ch.cover = true
|
|
52
|
+
StructNode.new(type: :cover, chapter: ch)
|
|
53
|
+
when :chapter
|
|
54
|
+
chapter_num += 1
|
|
55
|
+
ch = load_chapter(entry.path)
|
|
56
|
+
ch.number = chapter_num if @config.chapter_numbers
|
|
57
|
+
StructNode.new(type: :chapter, chapter: ch)
|
|
58
|
+
when :part
|
|
59
|
+
part_ch = load_chapter(entry.path)
|
|
60
|
+
part_ch.part_title = true
|
|
61
|
+
children = (entry.children || []).map do |child|
|
|
62
|
+
chapter_num += 1
|
|
63
|
+
ch = load_chapter(child.path)
|
|
64
|
+
ch.number = chapter_num if @config.chapter_numbers
|
|
65
|
+
StructNode.new(type: :chapter, chapter: ch)
|
|
66
|
+
end
|
|
67
|
+
StructNode.new(type: :part, chapter: part_ch, children: children)
|
|
68
|
+
when :appendix_group
|
|
69
|
+
children = (entry.children || []).map do |child|
|
|
70
|
+
appendix_num += 1
|
|
71
|
+
ch = load_chapter(child.path)
|
|
72
|
+
letter = ("A".ord + appendix_num - 1).chr
|
|
73
|
+
ch.appendix_letter = letter if @config.chapter_numbers
|
|
74
|
+
StructNode.new(type: :chapter, chapter: ch)
|
|
75
|
+
end
|
|
76
|
+
StructNode.new(type: :appendix_group, children: children)
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def load_chapter(path)
|
|
82
|
+
unless File.exist?(path)
|
|
83
|
+
abort "Error: chapter not found: #{path}"
|
|
84
|
+
end
|
|
85
|
+
Chapter.new(path, @config.base_dir)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def collect_all_chapters(structure)
|
|
89
|
+
structure.flat_map do |node|
|
|
90
|
+
case node.type
|
|
91
|
+
when :cover, :chapter
|
|
92
|
+
[node.chapter]
|
|
93
|
+
when :part
|
|
94
|
+
[node.chapter] + (node.children || []).map(&:chapter)
|
|
95
|
+
when :appendix_group
|
|
96
|
+
(node.children || []).map(&:chapter)
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def assign_relative_paths(chapters)
|
|
102
|
+
git_root = find_git_root(@config.base_dir)
|
|
103
|
+
chapters.each do |ch|
|
|
104
|
+
abs = File.expand_path(ch.instance_variable_get(:@path))
|
|
105
|
+
ch.relative_path = if git_root
|
|
106
|
+
abs.sub("#{git_root}/", "")
|
|
107
|
+
else
|
|
108
|
+
abs.sub("#{File.expand_path(@config.base_dir)}/", "")
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def find_git_root(dir)
|
|
114
|
+
dir = File.expand_path(dir)
|
|
115
|
+
loop do
|
|
116
|
+
return dir if File.directory?(File.join(dir, ".git"))
|
|
117
|
+
parent = File.dirname(dir)
|
|
118
|
+
return nil if parent == dir
|
|
119
|
+
dir = parent
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def copy_images
|
|
124
|
+
images_dir = File.join(@config.base_dir, "images")
|
|
125
|
+
return unless Dir.exist?(images_dir)
|
|
126
|
+
|
|
127
|
+
dest = File.join(@config.output_path, "images")
|
|
128
|
+
FileUtils.mkdir_p(dest)
|
|
129
|
+
|
|
130
|
+
Dir.glob(File.join(images_dir, "*")).each do |img|
|
|
131
|
+
FileUtils.cp(img, dest)
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
end
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "kramdown"
|
|
4
|
+
require "kramdown-parser-gfm"
|
|
5
|
+
|
|
6
|
+
module Ligarb
|
|
7
|
+
class Chapter
|
|
8
|
+
attr_reader :title, :slug, :html, :headings, :number, :appendix_letter
|
|
9
|
+
attr_accessor :part_title, :cover, :relative_path
|
|
10
|
+
|
|
11
|
+
Heading = Struct.new(:level, :text, :id, :display_text, keyword_init: true)
|
|
12
|
+
|
|
13
|
+
def initialize(path, base_dir)
|
|
14
|
+
@path = path
|
|
15
|
+
@base_dir = base_dir
|
|
16
|
+
@source = File.read(path)
|
|
17
|
+
@number = nil
|
|
18
|
+
@appendix_letter = nil
|
|
19
|
+
@part_title = false
|
|
20
|
+
@cover = false
|
|
21
|
+
|
|
22
|
+
@relative_path = nil
|
|
23
|
+
@slug = File.basename(path, ".md").gsub(/[^a-zA-Z0-9_-]/, "-")
|
|
24
|
+
parse!
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def number=(n)
|
|
28
|
+
@number = n
|
|
29
|
+
apply_section_numbers! if n
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def appendix_letter=(letter)
|
|
33
|
+
@appendix_letter = letter
|
|
34
|
+
apply_appendix_numbers!(letter) if letter
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def part_title?
|
|
38
|
+
@part_title
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def cover?
|
|
42
|
+
@cover
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def display_title
|
|
46
|
+
if @appendix_letter
|
|
47
|
+
"#{@appendix_letter}. #{@title}"
|
|
48
|
+
elsif @number
|
|
49
|
+
"#{@number}. #{@title}"
|
|
50
|
+
else
|
|
51
|
+
@title
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
private
|
|
56
|
+
|
|
57
|
+
def parse!
|
|
58
|
+
doc = Kramdown::Document.new(@source, input: "GFM", hard_wrap: false)
|
|
59
|
+
@headings = extract_headings(doc.root)
|
|
60
|
+
@html = rewrite_image_paths(doc.to_html)
|
|
61
|
+
@html = apply_heading_ids(@html)
|
|
62
|
+
@html = convert_special_code_blocks(@html)
|
|
63
|
+
@html = scope_footnote_ids(@html)
|
|
64
|
+
@title = @headings.first&.text || @slug
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def extract_headings(root)
|
|
68
|
+
headings = []
|
|
69
|
+
walk(root) do |el|
|
|
70
|
+
if el.type == :header && el.options[:level] <= 3
|
|
71
|
+
text = extract_text(el)
|
|
72
|
+
id = generate_id(text)
|
|
73
|
+
headings << Heading.new(level: el.options[:level], text: text, id: id, display_text: text)
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
headings
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def walk(el, &block)
|
|
80
|
+
yield el
|
|
81
|
+
el.children.each { |child| walk(child, &block) } if el.respond_to?(:children)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def extract_text(el)
|
|
85
|
+
if el.respond_to?(:children) && !el.children.empty?
|
|
86
|
+
el.children.map { |c| extract_text(c) }.join
|
|
87
|
+
elsif el.type == :text || el.type == :codespan
|
|
88
|
+
el.value
|
|
89
|
+
else
|
|
90
|
+
""
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def generate_id(text)
|
|
95
|
+
text.downcase
|
|
96
|
+
.gsub(/[^\p{L}\p{N}\s_-]/u, "") # keep letters (any script), digits, spaces, _, -
|
|
97
|
+
.strip
|
|
98
|
+
.gsub(/\s+/, "-")
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def apply_heading_ids(html)
|
|
102
|
+
heading_index = 0
|
|
103
|
+
html.gsub(%r{<(h[123])(\s[^>]*)?>}m) do
|
|
104
|
+
tag = $1
|
|
105
|
+
attrs = $2 || ""
|
|
106
|
+
if heading_index < @headings.length
|
|
107
|
+
full_id = "#{@slug}--#{@headings[heading_index].id}"
|
|
108
|
+
heading_index += 1
|
|
109
|
+
# Replace existing id or add new one
|
|
110
|
+
if attrs =~ /id="/
|
|
111
|
+
"<#{tag}#{attrs.sub(/id="[^"]*"/, "id=\"#{full_id}\"")}>".squeeze(" ")
|
|
112
|
+
else
|
|
113
|
+
"<#{tag} id=\"#{full_id}\"#{attrs}>"
|
|
114
|
+
end
|
|
115
|
+
else
|
|
116
|
+
"<#{tag}#{attrs}>"
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def convert_special_code_blocks(html)
|
|
122
|
+
html.gsub(%r{<pre><code class="language-(mermaid|math)">(.*?)</code></pre>}m) do
|
|
123
|
+
lang = $1
|
|
124
|
+
raw = decode_entities($2)
|
|
125
|
+
case lang
|
|
126
|
+
when "mermaid"
|
|
127
|
+
%(<div class="mermaid">\n#{raw}</div>)
|
|
128
|
+
when "math"
|
|
129
|
+
%(<div class="math-block" data-math="#{encode_attr(raw)}"></div>)
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def decode_entities(text)
|
|
135
|
+
text.gsub("&", "&").gsub("<", "<").gsub(">", ">").gsub(""", '"').gsub("'", "'")
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def encode_attr(text)
|
|
139
|
+
text.gsub("&", "&").gsub("<", "<").gsub(">", ">").gsub('"', """)
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def scope_footnote_ids(html)
|
|
143
|
+
html.gsub(/(id="|href="#)(fn:|fnref:)(\w+)/) do
|
|
144
|
+
"#{$1}#{$2}#{@slug}--#{$3}"
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def rewrite_image_paths(html)
|
|
149
|
+
html.gsub(/(<img\s[^>]*src=")([^"]+)(")/) do
|
|
150
|
+
prefix = $1
|
|
151
|
+
src = $2
|
|
152
|
+
suffix = $3
|
|
153
|
+
|
|
154
|
+
if src.start_with?("http://", "https://", "data:")
|
|
155
|
+
"#{prefix}#{src}#{suffix}"
|
|
156
|
+
else
|
|
157
|
+
basename = File.basename(src)
|
|
158
|
+
"#{prefix}images/#{basename}#{suffix}"
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def apply_appendix_numbers!(letter)
|
|
164
|
+
h2_count = 0
|
|
165
|
+
h3_count = 0
|
|
166
|
+
|
|
167
|
+
@headings.each do |heading|
|
|
168
|
+
case heading.level
|
|
169
|
+
when 1
|
|
170
|
+
heading.display_text = "#{letter}. #{heading.text}"
|
|
171
|
+
when 2
|
|
172
|
+
h2_count += 1
|
|
173
|
+
h3_count = 0
|
|
174
|
+
heading.display_text = "#{letter}.#{h2_count} #{heading.text}"
|
|
175
|
+
when 3
|
|
176
|
+
h3_count += 1
|
|
177
|
+
heading.display_text = "#{letter}.#{h2_count}.#{h3_count} #{heading.text}"
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
h2_count = 0
|
|
182
|
+
h3_count = 0
|
|
183
|
+
@html = @html.gsub(%r{<(h[123])(\s[^>]*)?>(.+?)</\1>}m) do
|
|
184
|
+
tag = $1
|
|
185
|
+
attrs = $2 || ""
|
|
186
|
+
content = $3
|
|
187
|
+
|
|
188
|
+
numbered = case tag
|
|
189
|
+
when "h1"
|
|
190
|
+
"#{letter}. #{content}"
|
|
191
|
+
when "h2"
|
|
192
|
+
h2_count += 1
|
|
193
|
+
h3_count = 0
|
|
194
|
+
"#{letter}.#{h2_count} #{content}"
|
|
195
|
+
when "h3"
|
|
196
|
+
h3_count += 1
|
|
197
|
+
"#{letter}.#{h2_count}.#{h3_count} #{content}"
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
"<#{tag}#{attrs}>#{numbered}</#{tag}>"
|
|
201
|
+
end
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
def apply_section_numbers!
|
|
205
|
+
h2_count = 0
|
|
206
|
+
h3_count = 0
|
|
207
|
+
|
|
208
|
+
@headings.each do |heading|
|
|
209
|
+
case heading.level
|
|
210
|
+
when 1
|
|
211
|
+
heading.display_text = "#{@number}. #{heading.text}"
|
|
212
|
+
when 2
|
|
213
|
+
h2_count += 1
|
|
214
|
+
h3_count = 0
|
|
215
|
+
heading.display_text = "#{@number}.#{h2_count} #{heading.text}"
|
|
216
|
+
when 3
|
|
217
|
+
h3_count += 1
|
|
218
|
+
heading.display_text = "#{@number}.#{h2_count}.#{h3_count} #{heading.text}"
|
|
219
|
+
end
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
# Rewrite HTML headings to include section numbers
|
|
223
|
+
h2_count = 0
|
|
224
|
+
h3_count = 0
|
|
225
|
+
@html = @html.gsub(%r{<(h[123])(\s[^>]*)?>(.+?)</\1>}m) do
|
|
226
|
+
tag = $1
|
|
227
|
+
attrs = $2 || ""
|
|
228
|
+
content = $3
|
|
229
|
+
|
|
230
|
+
numbered = case tag
|
|
231
|
+
when "h1"
|
|
232
|
+
"#{@number}. #{content}"
|
|
233
|
+
when "h2"
|
|
234
|
+
h2_count += 1
|
|
235
|
+
h3_count = 0
|
|
236
|
+
"#{@number}.#{h2_count} #{content}"
|
|
237
|
+
when "h3"
|
|
238
|
+
h3_count += 1
|
|
239
|
+
"#{@number}.#{h2_count}.#{h3_count} #{content}"
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
"<#{tag}#{attrs}>#{numbered}</#{tag}>"
|
|
243
|
+
end
|
|
244
|
+
end
|
|
245
|
+
end
|
|
246
|
+
end
|