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.
@@ -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("&amp;", "&").gsub("&lt;", "<").gsub("&gt;", ">").gsub("&quot;", '"').gsub("&#39;", "'")
136
+ end
137
+
138
+ def encode_attr(text)
139
+ text.gsub("&", "&amp;").gsub("<", "&lt;").gsub(">", "&gt;").gsub('"', "&quot;")
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