ligarb 0.1.0 → 0.3.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: 6cc0976cad1fb731ff5ad56f38d7ee7142a0deecc158b70482f017df2611b678
4
- data.tar.gz: 802ec24c33f96792addf6939cf92e04e7163e1b6864958dbfef7b8a02d224645
3
+ metadata.gz: 20b29dff31e22937e2ab71a7011d1ed2fb193c8717753c7a23500767645807f1
4
+ data.tar.gz: be6404f8dccb7cea765b8635f9663b57f0c23e73253a29da6ac49ca0017baae1
5
5
  SHA512:
6
- metadata.gz: 58807bf5f3088e399d395761e75e65d52933db36ce116326e30a88b0ccb87425d4cde2644c89912e10b772f5565238b15384ada2fdd2ea261509eceded09ab97
7
- data.tar.gz: 36069084a2b167996bf2310aa2bd17ee0689d59b52941fbb295b2c623eeb3f34e24a8ccd95bb12751ce23782c9df1374f3c8df8eaa46d24742f63beed146ec87
6
+ metadata.gz: 9ca9e973840571772b22b493ddfbc15b87c844abf05e84c2805c3d63ede347a3e245745e5bab2b8d2a3cb8d7b13b3c6c9d94f70b2d5c6d572eefadc11a839d64
7
+ data.tar.gz: 98f30f871f31b96331ebac03a4e0bb85690e612e14dd0412592ff2a1400a897698807d206f851143561e27bd384d32273517531708c2f1af7ee59aaaedf33c17
data/assets/style.css CHANGED
@@ -376,6 +376,53 @@ mark.search-highlight {
376
376
  border-radius: 2px;
377
377
  }
378
378
 
379
+ /* === Admonitions === */
380
+ .admonition {
381
+ border-left: 4px solid;
382
+ border-radius: 0 4px 4px 0;
383
+ padding: 0.75rem 1rem;
384
+ margin-bottom: 1rem;
385
+ }
386
+
387
+ .admonition p:last-child {
388
+ margin-bottom: 0;
389
+ }
390
+
391
+ .admonition-title {
392
+ font-weight: 600;
393
+ margin-bottom: 0.4rem !important;
394
+ }
395
+
396
+ .admonition-note {
397
+ border-left-color: #2563eb;
398
+ background: #eff6ff;
399
+ }
400
+ .admonition-note .admonition-title::before { content: "\2139\FE0F "; }
401
+
402
+ .admonition-tip {
403
+ border-left-color: #16a34a;
404
+ background: #f0fdf4;
405
+ }
406
+ .admonition-tip .admonition-title::before { content: "\1F4A1 "; }
407
+
408
+ .admonition-warning {
409
+ border-left-color: #ca8a04;
410
+ background: #fefce8;
411
+ }
412
+ .admonition-warning .admonition-title::before { content: "\26A0\FE0F "; }
413
+
414
+ .admonition-caution {
415
+ border-left-color: #dc2626;
416
+ background: #fef2f2;
417
+ }
418
+ .admonition-caution .admonition-title::before { content: "\1F6D1 "; }
419
+
420
+ .admonition-important {
421
+ border-left-color: #9333ea;
422
+ background: #faf5ff;
423
+ }
424
+ .admonition-important .admonition-title::before { content: "\2757 "; }
425
+
379
426
  /* === Dark Mode === */
380
427
  [data-theme="dark"] {
381
428
  --color-bg: #1a1a2e;
@@ -390,6 +437,22 @@ mark.search-highlight {
390
437
  --color-code-border: #2d3748;
391
438
  }
392
439
 
440
+ [data-theme="dark"] .admonition-note {
441
+ background: #1e2a3a;
442
+ }
443
+ [data-theme="dark"] .admonition-tip {
444
+ background: #1a2e1a;
445
+ }
446
+ [data-theme="dark"] .admonition-warning {
447
+ background: #2e2a1a;
448
+ }
449
+ [data-theme="dark"] .admonition-caution {
450
+ background: #2e1a1a;
451
+ }
452
+ [data-theme="dark"] .admonition-important {
453
+ background: #2a1a2e;
454
+ }
455
+
393
456
  [data-theme="dark"] .search-box input {
394
457
  background: var(--color-code-bg);
395
458
  color: var(--color-text);
@@ -561,6 +624,52 @@ mark.search-highlight {
561
624
  }
562
625
  }
563
626
 
627
+ /* === Index === */
628
+ .index-group {
629
+ margin-bottom: 1.5rem;
630
+ }
631
+
632
+ .index-letter {
633
+ font-size: 1.3rem;
634
+ font-weight: 700;
635
+ color: var(--color-accent);
636
+ border-bottom: 2px solid var(--color-border);
637
+ padding-bottom: 0.25rem;
638
+ margin-bottom: 0.5rem;
639
+ }
640
+
641
+ .index-entries {
642
+ margin-left: 0;
643
+ }
644
+
645
+ .index-entries dt {
646
+ font-weight: 600;
647
+ margin-top: 0.4rem;
648
+ }
649
+
650
+ .index-entries dt.index-sub {
651
+ font-weight: 400;
652
+ margin-left: 1.5rem;
653
+ }
654
+
655
+ .index-entries dd {
656
+ margin-left: 1.5rem;
657
+ font-size: 0.92rem;
658
+ }
659
+
660
+ .index-entries dd.index-sub {
661
+ margin-left: 3rem;
662
+ }
663
+
664
+ .index-entries dd a {
665
+ color: var(--color-accent);
666
+ text-decoration: none;
667
+ }
668
+
669
+ .index-entries dd a:hover {
670
+ text-decoration: underline;
671
+ }
672
+
564
673
  /* === Print === */
565
674
  @media print {
566
675
  .sidebar,
@@ -570,6 +679,14 @@ mark.search-highlight {
570
679
  display: none !important;
571
680
  }
572
681
 
682
+ html {
683
+ font-size: 11pt;
684
+ }
685
+
686
+ body {
687
+ line-height: 1.5;
688
+ }
689
+
573
690
  .content {
574
691
  margin-left: 0;
575
692
  max-width: 100%;
@@ -589,4 +706,17 @@ mark.search-highlight {
589
706
  white-space: pre-wrap;
590
707
  word-wrap: break-word;
591
708
  }
709
+
710
+ .chapter pre code {
711
+ font-size: 0.8rem;
712
+ }
713
+
714
+ @page {
715
+ margin: 20mm 15mm;
716
+ @bottom-center {
717
+ content: counter(page);
718
+ font-size: 9pt;
719
+ color: #666;
720
+ }
721
+ }
592
722
  }
@@ -16,14 +16,23 @@ module Ligarb
16
16
  structure = load_structure
17
17
 
18
18
  all_chapters = collect_all_chapters(structure)
19
+ resolve_cross_references(all_chapters)
19
20
  assign_relative_paths(all_chapters) if @config.repository
20
21
 
21
22
  assets = AssetManager.new(@config.output_path)
22
23
  assets.detect(all_chapters)
23
24
  assets.provision!
24
25
 
26
+ index_entries = all_chapters.flat_map { |ch|
27
+ ch.index_entries.map { |e|
28
+ e.class.new(term: e.term, display_text: e.display_text,
29
+ chapter_slug: e.chapter_slug, anchor_id: e.anchor_id)
30
+ }
31
+ }
32
+
25
33
  html = Template.new.render(config: @config, chapters: all_chapters,
26
- structure: structure, assets: assets)
34
+ structure: structure, assets: assets,
35
+ index_entries: index_entries)
27
36
 
28
37
  FileUtils.mkdir_p(@config.output_path)
29
38
  output_file = File.join(@config.output_path, "index.html")
@@ -98,6 +107,22 @@ module Ligarb
98
107
  end
99
108
  end
100
109
 
110
+ def resolve_cross_references(all_chapters)
111
+ chapter_map = {}
112
+ all_chapters.each do |ch|
113
+ abs_path = File.expand_path(ch.instance_variable_get(:@path))
114
+ chapter_map[abs_path] = {
115
+ slug: ch.slug,
116
+ chapter: ch,
117
+ headings: ch.headings.each_with_object({}) { |h, map| map[h.id] = h }
118
+ }
119
+ end
120
+
121
+ all_chapters.each do |ch|
122
+ ch.resolve_cross_references!(chapter_map)
123
+ end
124
+ end
125
+
101
126
  def assign_relative_paths(chapters)
102
127
  git_root = find_git_root(@config.base_dir)
103
128
  chapters.each do |ch|
@@ -5,10 +5,13 @@ require "kramdown-parser-gfm"
5
5
 
6
6
  module Ligarb
7
7
  class Chapter
8
- attr_reader :title, :slug, :html, :headings, :number, :appendix_letter
8
+ class CrossReferenceError < StandardError; end
9
+
10
+ attr_reader :title, :slug, :html, :headings, :number, :appendix_letter, :index_entries
9
11
  attr_accessor :part_title, :cover, :relative_path
10
12
 
11
13
  Heading = Struct.new(:level, :text, :id, :display_text, keyword_init: true)
14
+ IndexEntry = Struct.new(:term, :display_text, :chapter_slug, :anchor_id, keyword_init: true)
12
15
 
13
16
  def initialize(path, base_dir)
14
17
  @path = path
@@ -42,6 +45,44 @@ module Ligarb
42
45
  @cover
43
46
  end
44
47
 
48
+ def self.generate_id(text)
49
+ text.downcase
50
+ .gsub(/[^\p{L}\p{N}\s_-]/u, "")
51
+ .strip
52
+ .gsub(/\s+/, "-")
53
+ end
54
+
55
+ def resolve_cross_references!(chapter_map)
56
+ source_dir = File.dirname(@path)
57
+
58
+ @html = @html.gsub(%r{<a\s+href="((?!https?://)[^"]+\.md)(?:#([^"]*))?">(.*?)</a>}m) do
59
+ href_path = $1
60
+ fragment = $2
61
+ link_text = $3
62
+
63
+ target_path = File.expand_path(href_path, source_dir)
64
+ entry = chapter_map[target_path]
65
+ unless entry
66
+ raise CrossReferenceError, "cross-reference target not found: #{href_path} (from #{File.basename(@path)})"
67
+ end
68
+
69
+ if fragment && !fragment.empty?
70
+ normalized = self.class.generate_id(fragment)
71
+ heading = entry[:headings][normalized]
72
+ unless heading
73
+ raise CrossReferenceError, "cross-reference heading not found: #{href_path}##{fragment} (from #{File.basename(@path)})"
74
+ end
75
+ anchor = "#{entry[:slug]}--#{heading.id}"
76
+ text = link_text.empty? ? heading.display_text : link_text
77
+ else
78
+ anchor = entry[:slug]
79
+ text = link_text.empty? ? entry[:chapter].display_title : link_text
80
+ end
81
+
82
+ %(<a href="##{anchor}">#{text}</a>)
83
+ end
84
+ end
85
+
45
86
  def display_title
46
87
  if @appendix_letter
47
88
  "#{@appendix_letter}. #{@title}"
@@ -60,7 +101,10 @@ module Ligarb
60
101
  @html = rewrite_image_paths(doc.to_html)
61
102
  @html = apply_heading_ids(@html)
62
103
  @html = convert_special_code_blocks(@html)
104
+ @html = convert_admonitions(@html)
63
105
  @html = scope_footnote_ids(@html)
106
+ @index_entries = []
107
+ @html = extract_index_markers(@html)
64
108
  @title = @headings.first&.text || @slug
65
109
  end
66
110
 
@@ -92,10 +136,7 @@ module Ligarb
92
136
  end
93
137
 
94
138
  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+/, "-")
139
+ self.class.generate_id(text)
99
140
  end
100
141
 
101
142
  def apply_heading_ids(html)
@@ -139,12 +180,65 @@ module Ligarb
139
180
  text.gsub("&", "&amp;").gsub("<", "&lt;").gsub(">", "&gt;").gsub('"', "&quot;")
140
181
  end
141
182
 
183
+ ADMONITION_TITLES = {
184
+ "NOTE" => "Note",
185
+ "TIP" => "Tip",
186
+ "WARNING" => "Warning",
187
+ "CAUTION" => "Caution",
188
+ "IMPORTANT" => "Important",
189
+ }.freeze
190
+
191
+ def convert_admonitions(html)
192
+ html.gsub(%r{<blockquote>\s*<p>\[!(NOTE|TIP|WARNING|CAUTION|IMPORTANT)\]\s*\n?(.*?)</p>(.*?)</blockquote>}m) do
193
+ type = $1
194
+ first_content = $2.strip
195
+ rest = $3
196
+ css_class = type.downcase
197
+ title = ADMONITION_TITLES[type]
198
+
199
+ inner = if first_content.empty?
200
+ rest
201
+ else
202
+ "<p>#{first_content}</p>#{rest}"
203
+ end
204
+
205
+ %(<div class="admonition admonition-#{css_class}">\n<p class="admonition-title">#{title}</p>\n#{inner}</div>)
206
+ end
207
+ end
208
+
142
209
  def scope_footnote_ids(html)
143
210
  html.gsub(/(id="|href="#)(fn:|fnref:)(\w+)/) do
144
211
  "#{$1}#{$2}#{@slug}--#{$3}"
145
212
  end
146
213
  end
147
214
 
215
+ def extract_index_markers(html)
216
+ idx_count = 0
217
+ html.gsub(%r{<a\s+href="#index(?::([^"]*))?">(.*?)</a>}m) do
218
+ terms_str = $1
219
+ display_text = $2
220
+ anchor_id = "#{@slug}--idx-#{idx_count}"
221
+ idx_count += 1
222
+
223
+ terms = if terms_str && !terms_str.empty?
224
+ terms_str.split(",").map(&:strip)
225
+ else
226
+ [display_text.gsub(/<[^>]+>/, "")] # strip any HTML tags for the term
227
+ end
228
+
229
+ terms.each do |term|
230
+ @index_entries << IndexEntry.new(
231
+ term: term,
232
+ display_text: display_text,
233
+ chapter_slug: @slug,
234
+ anchor_id: anchor_id
235
+ )
236
+ end
237
+
238
+ %(<span id="#{anchor_id}">#{display_text}</span>)
239
+ end
240
+ end
241
+
148
242
  def rewrite_image_paths(html)
149
243
  html.gsub(/(<img\s[^>]*src=")([^"]+)(")/) do
150
244
  prefix = $1
data/lib/ligarb/cli.rb CHANGED
@@ -296,6 +296,21 @@ module Ligarb
296
296
  Footnote IDs are scoped per chapter to avoid collisions in the single-page
297
297
  output.
298
298
 
299
+ == Index ==
300
+
301
+ Mark terms for the book index using Markdown link syntax with #index:
302
+
303
+ [Ruby](#index) Index the link text as-is
304
+ [dynamic typing](#index:動的型付け) Index under a specific term
305
+ [Ruby](#index:Ruby,Languages/Ruby) Multiple index entries (comma-separated)
306
+ [Ruby](#index:Languages/Ruby) Hierarchical: Languages > Ruby
307
+
308
+ The link is rendered as plain text in the output (no link styling).
309
+ An "Index" section is automatically appended at the end of the book,
310
+ with terms sorted alphabetically and grouped by first character.
311
+
312
+ Clicking an index entry navigates to the exact location in the chapter.
313
+
299
314
  == Custom CSS ==
300
315
 
301
316
  Add a 'style' field to book.yml to inject custom CSS:
@@ -330,6 +345,62 @@ module Ligarb
330
345
  Each chapter will show a "View on GitHub" link pointing to:
331
346
  {repository}/blob/HEAD/{path-from-git-root}
332
347
 
348
+ == Admonitions ==
349
+
350
+ GFM-style blockquote alerts are converted to styled admonition boxes.
351
+ Five types are supported: NOTE, TIP, WARNING, CAUTION, IMPORTANT.
352
+
353
+ Syntax:
354
+
355
+ > [!NOTE]
356
+ > This is a note.
357
+
358
+ > [!TIP]
359
+ > Helpful advice here.
360
+
361
+ > [!WARNING]
362
+ > Be careful about this.
363
+
364
+ > [!CAUTION]
365
+ > Dangerous operation.
366
+
367
+ > [!IMPORTANT]
368
+ > Critical information.
369
+
370
+ Each type renders with a distinct color and icon:
371
+ - NOTE: blue (info)
372
+ - TIP: green (lightbulb)
373
+ - WARNING: yellow (warning)
374
+ - CAUTION: red (stop)
375
+ - IMPORTANT: purple (exclamation)
376
+
377
+ == Cross-References ==
378
+
379
+ Link to other chapters or headings using standard Markdown relative links.
380
+ ligarb resolves .md file references to internal anchors in the single-page
381
+ output.
382
+
383
+ Syntax:
384
+
385
+ [link text](other-chapter.md) Link to a chapter
386
+ [link text](other-chapter.md#Heading) Link to a specific heading
387
+ [](other-chapter.md) Auto-fill with chapter title
388
+ [](other-chapter.md#Heading) Auto-fill with heading text
389
+
390
+ The .md path is resolved relative to the current Markdown file's directory.
391
+ The heading fragment is matched against heading IDs (case-insensitive,
392
+ normalized the same way heading slugs are generated).
393
+
394
+ When the link text is empty, ligarb fills it with the target's display text:
395
+ - Chapter link: the chapter's display title (e.g. "3. Config Guide")
396
+ - Heading link: the heading's display text (e.g. "3.2 Setup")
397
+
398
+ If a referenced chapter or heading does not exist, the build fails with an
399
+ error message indicating the broken reference and its source file.
400
+
401
+ External URLs ending in .md (e.g. https://example.com/README.md) are not
402
+ affected — only relative paths are resolved.
403
+
333
404
  == Previous/Next Navigation ==
334
405
 
335
406
  Each chapter displays Previous and Next navigation links at the bottom.
@@ -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:)
15
+ def render(config:, chapters:, structure:, assets:, index_entries: [])
16
16
  css = File.read(@css_path)
17
17
  template = File.read(@template_path)
18
18
 
@@ -31,8 +31,72 @@ 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(:index_tree, build_index_tree(index_entries, chapters))
34
35
 
35
36
  ERB.new(template, trim_mode: "-").result(b)
36
37
  end
38
+
39
+ private
40
+
41
+ # Build a sorted tree structure for the index.
42
+ # Returns: { "A" => [ { term: "Algorithm", refs: [...] },
43
+ # { term: "Array", refs: [...], children: [ { term: "sort", refs: [...] } ] } ],
44
+ # ... }
45
+ def build_index_tree(entries, chapters)
46
+ return {} if entries.empty?
47
+
48
+ chapter_titles = chapters.each_with_object({}) { |ch, h| h[ch.slug] = ch.display_title }
49
+
50
+ # Group by full term, collecting refs
51
+ term_refs = {}
52
+ entries.each do |e|
53
+ parts = e.term.split("/", 2)
54
+ top = parts[0]
55
+ sub = parts[1]
56
+
57
+ key = sub ? [top, sub] : [top]
58
+ term_refs[key] ||= []
59
+ term_refs[key] << { chapter_slug: e.chapter_slug,
60
+ chapter_title: chapter_titles[e.chapter_slug] || e.chapter_slug,
61
+ anchor_id: e.anchor_id }
62
+ end
63
+
64
+ # Build nested structure grouped by first character
65
+ nested = {}
66
+ term_refs.each do |key, refs|
67
+ top = key[0]
68
+ sub = key[1]
69
+
70
+ nested[top] ||= { refs: [], children: {} }
71
+ if sub
72
+ nested[top][:children][sub] ||= []
73
+ nested[top][:children][sub].concat(refs)
74
+ else
75
+ nested[top][:refs].concat(refs)
76
+ end
77
+ end
78
+
79
+ # Group by first character and sort
80
+ grouped = {}
81
+ nested.sort_by { |k, _| k }.each do |term, data|
82
+ letter = first_letter(term)
83
+ grouped[letter] ||= []
84
+ children = data[:children].sort_by { |k, _| k }.map { |k, v| { term: k, refs: v } }
85
+ grouped[letter] << { term: term, refs: data[:refs], children: children }
86
+ end
87
+
88
+ grouped
89
+ end
90
+
91
+ def first_letter(term)
92
+ ch = term[0]
93
+ if ch&.match?(/[a-zA-Z]/)
94
+ ch.upcase
95
+ elsif ch&.match?(/\p{Hiragana}|\p{Katakana}/)
96
+ ch
97
+ else
98
+ ch || "#"
99
+ end
100
+ end
37
101
  end
38
102
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Ligarb
4
- VERSION = "0.1.0"
4
+ VERSION = "0.3.0"
5
5
  end
@@ -103,6 +103,11 @@
103
103
  </li>
104
104
  <%- end -%>
105
105
  <%- end -%>
106
+ <%- unless index_tree.empty? -%>
107
+ <li class="toc-chapter" data-chapter="__index__">
108
+ <a href="#__index__" class="toc-h1" onclick="showChapter('__index__')"><%= language == 'ja' ? '索引' : 'Index' %></a>
109
+ </li>
110
+ <%- end -%>
106
111
  </ul>
107
112
  </nav>
108
113
  </div>
@@ -132,11 +137,35 @@
132
137
  <%- end -%>
133
138
  </section>
134
139
  <%- end -%>
140
+ <%- unless index_tree.empty? -%>
141
+ <section class="chapter index-chapter" id="chapter-__index__" style="display: none;">
142
+ <h1><%= language == 'ja' ? '索引' : 'Index' %></h1>
143
+ <%- index_tree.keys.sort.each do |letter| -%>
144
+ <div class="index-group">
145
+ <h2 class="index-letter"><%= letter %></h2>
146
+ <dl class="index-entries">
147
+ <%- index_tree[letter].each do |item| -%>
148
+ <dt><%= item[:term] %></dt>
149
+ <%- item[:refs].each do |ref| -%>
150
+ <dd><a href="#<%= ref[:anchor_id] %>" onclick="showChapterAndScroll('<%= ref[:chapter_slug] %>', '<%= ref[:anchor_id] %>'); return false;"><%= ref[:chapter_title] %></a></dd>
151
+ <%- end -%>
152
+ <%- item[:children].each do |child| -%>
153
+ <dt class="index-sub"><%= child[:term] %></dt>
154
+ <%- child[:refs].each do |ref| -%>
155
+ <dd class="index-sub"><a href="#<%= ref[:anchor_id] %>" onclick="showChapterAndScroll('<%= ref[:chapter_slug] %>', '<%= ref[:anchor_id] %>'); return false;"><%= ref[:chapter_title] %></a></dd>
156
+ <%- end -%>
157
+ <%- end -%>
158
+ <%- end -%>
159
+ </dl>
160
+ </div>
161
+ <%- end -%>
162
+ </section>
163
+ <%- end -%>
135
164
  </main>
136
165
 
137
166
  <script>
138
167
  (function() {
139
- var chapters = [<%= chapters.map { |c| "\"#{c.slug}\"" }.join(", ") %>];
168
+ var chapters = [<%= chapters.map { |c| "\"#{c.slug}\"" }.join(", ") %><%= ', "__index__"' unless index_tree.empty? %>];
140
169
  var currentChapter = null;
141
170
 
142
171
  function showChapter(slug) {
@@ -163,6 +192,26 @@
163
192
  if (searchInput && searchInput.value) {
164
193
  highlightContent(searchInput.value);
165
194
  }
195
+
196
+ // Render mermaid/KaTeX in newly visible chapter if not yet rendered
197
+ renderSpecialBlocks(slug);
198
+ }
199
+
200
+ function renderSpecialBlocks(slug) {
201
+ var section = document.getElementById('chapter-' + slug);
202
+ if (!section) return;
203
+ if (typeof mermaid !== 'undefined') {
204
+ var unrendered = section.querySelectorAll('.mermaid:not([data-processed])');
205
+ if (unrendered.length > 0) mermaid.run({nodes: unrendered});
206
+ }
207
+ if (typeof katex !== 'undefined') {
208
+ section.querySelectorAll('.math-block[data-math]').forEach(function(el) {
209
+ if (el.childNodes.length === 0) {
210
+ try { katex.render(el.getAttribute('data-math'), el, {displayMode: true, throwOnError: false}); }
211
+ catch(e) { el.textContent = el.getAttribute('data-math'); }
212
+ }
213
+ });
214
+ }
166
215
  }
167
216
 
168
217
  function showChapterAndScroll(slug, headingId) {
@@ -188,6 +237,18 @@
188
237
  return;
189
238
  }
190
239
 
240
+ // Check for footnote links (fn: or fnref:)
241
+ var fnMatch = hash.match(/^(?:fn|fnref):(.+?)--/);
242
+ if (fnMatch) {
243
+ var fnChSlug = fnMatch[1];
244
+ if (chapters.indexOf(fnChSlug) !== -1) {
245
+ if (currentChapter !== fnChSlug) showChapter(fnChSlug);
246
+ var fnTarget = document.getElementById(hash);
247
+ if (fnTarget) fnTarget.scrollIntoView({ behavior: 'smooth' });
248
+ return;
249
+ }
250
+ }
251
+
191
252
  // Check for deep link (chapter--heading)
192
253
  var parts = hash.split('--');
193
254
  if (parts.length >= 2) {
@@ -302,9 +363,36 @@
302
363
  localStorage.setItem('ligarb-theme', next);
303
364
  });
304
365
 
305
- // Expose to onclick handlers
366
+ // Footnote link click handler — scroll within current chapter without triggering chapter switch
367
+ document.addEventListener('click', function(e) {
368
+ var link = e.target.closest('a[href^="#fn:"], a[href^="#fnref:"]');
369
+ if (!link) return;
370
+ var targetId = link.getAttribute('href').substring(1);
371
+ var target = document.getElementById(targetId);
372
+ if (target) {
373
+ e.preventDefault();
374
+ history.replaceState(null, '', '#' + targetId);
375
+ target.scrollIntoView({ behavior: 'smooth' });
376
+ }
377
+ });
378
+
379
+ // Add title attribute to footnote references for hover preview
380
+ document.querySelectorAll('sup a[href^="#fn:"]').forEach(function(link) {
381
+ var targetId = link.getAttribute('href').substring(1);
382
+ var footnoteLi = document.getElementById(targetId);
383
+ if (footnoteLi) {
384
+ // Clone to strip the back-reference link, then get text
385
+ var clone = footnoteLi.cloneNode(true);
386
+ var backlinks = clone.querySelectorAll('a[href^="#fnref:"]');
387
+ backlinks.forEach(function(bl) { bl.remove(); });
388
+ link.title = clone.textContent.trim();
389
+ }
390
+ });
391
+
392
+ // Expose to onclick handlers and external scripts
306
393
  window.showChapter = showChapter;
307
394
  window.showChapterAndScroll = showChapterAndScroll;
395
+ window.renderSpecialBlocks = function() { if (currentChapter) renderSpecialBlocks(currentChapter); };
308
396
 
309
397
  // Initialize
310
398
  handleHash();
@@ -319,17 +407,12 @@
319
407
  <script src="js/mermaid.min.js"></script>
320
408
  <script>
321
409
  mermaid.initialize({startOnLoad: false});
322
- mermaid.run({querySelector: '.mermaid'});
410
+ renderSpecialBlocks();
323
411
  </script>
324
412
  <%- end -%>
325
413
  <%- if assets.need?(:katex) -%>
326
414
  <script src="js/katex.min.js"></script>
327
- <script>
328
- document.querySelectorAll('.math-block').forEach(function(el) {
329
- try { katex.render(el.getAttribute('data-math'), el, {displayMode: true, throwOnError: false}); }
330
- catch(e) { el.textContent = el.getAttribute('data-math'); }
331
- });
332
- </script>
415
+ <script>renderSpecialBlocks();</script>
333
416
  <%- end -%>
334
417
  </body>
335
418
  </html>
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.1.0
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - ligarb contributors