ligarb 0.1.0 → 0.2.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: c88870d66d05c780d5dd46b93ebad2f0daffa3ce201d8a51b6cec8c61eedcb73
4
+ data.tar.gz: 3044c7b204c390fe1ed67f187598d50164e27d917e82dae9897878c0d418f334
5
5
  SHA512:
6
- metadata.gz: 58807bf5f3088e399d395761e75e65d52933db36ce116326e30a88b0ccb87425d4cde2644c89912e10b772f5565238b15384ada2fdd2ea261509eceded09ab97
7
- data.tar.gz: 36069084a2b167996bf2310aa2bd17ee0689d59b52941fbb295b2c623eeb3f34e24a8ccd95bb12751ce23782c9df1374f3c8df8eaa46d24742f63beed146ec87
6
+ metadata.gz: 8f1f24fa71f51560116db5ee1390701988e2640ed17dd4680124aad7c1f59ae82d9f513379ed38de3f8b187874399c41322f6649ea588b205e50ae760087bc62
7
+ data.tar.gz: 927eb04a33dcc3b24525a5899a412f852d2a0f872c6422b0034d881ed67ba27c0151b602e01d42ebf7900324ad3f4d9392d6b7843abc9ac472d9e707466b618c
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
  }
@@ -22,8 +22,16 @@ module Ligarb
22
22
  assets.detect(all_chapters)
23
23
  assets.provision!
24
24
 
25
+ index_entries = all_chapters.flat_map { |ch|
26
+ ch.index_entries.map { |e|
27
+ e.class.new(term: e.term, display_text: e.display_text,
28
+ chapter_slug: e.chapter_slug, anchor_id: e.anchor_id)
29
+ }
30
+ }
31
+
25
32
  html = Template.new.render(config: @config, chapters: all_chapters,
26
- structure: structure, assets: assets)
33
+ structure: structure, assets: assets,
34
+ index_entries: index_entries)
27
35
 
28
36
  FileUtils.mkdir_p(@config.output_path)
29
37
  output_file = File.join(@config.output_path, "index.html")
@@ -5,10 +5,11 @@ 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
+ attr_reader :title, :slug, :html, :headings, :number, :appendix_letter, :index_entries
9
9
  attr_accessor :part_title, :cover, :relative_path
10
10
 
11
11
  Heading = Struct.new(:level, :text, :id, :display_text, keyword_init: true)
12
+ IndexEntry = Struct.new(:term, :display_text, :chapter_slug, :anchor_id, keyword_init: true)
12
13
 
13
14
  def initialize(path, base_dir)
14
15
  @path = path
@@ -60,7 +61,10 @@ module Ligarb
60
61
  @html = rewrite_image_paths(doc.to_html)
61
62
  @html = apply_heading_ids(@html)
62
63
  @html = convert_special_code_blocks(@html)
64
+ @html = convert_admonitions(@html)
63
65
  @html = scope_footnote_ids(@html)
66
+ @index_entries = []
67
+ @html = extract_index_markers(@html)
64
68
  @title = @headings.first&.text || @slug
65
69
  end
66
70
 
@@ -139,12 +143,65 @@ module Ligarb
139
143
  text.gsub("&", "&amp;").gsub("<", "&lt;").gsub(">", "&gt;").gsub('"', "&quot;")
140
144
  end
141
145
 
146
+ ADMONITION_TITLES = {
147
+ "NOTE" => "Note",
148
+ "TIP" => "Tip",
149
+ "WARNING" => "Warning",
150
+ "CAUTION" => "Caution",
151
+ "IMPORTANT" => "Important",
152
+ }.freeze
153
+
154
+ def convert_admonitions(html)
155
+ html.gsub(%r{<blockquote>\s*<p>\[!(NOTE|TIP|WARNING|CAUTION|IMPORTANT)\]\s*\n?(.*?)</p>(.*?)</blockquote>}m) do
156
+ type = $1
157
+ first_content = $2.strip
158
+ rest = $3
159
+ css_class = type.downcase
160
+ title = ADMONITION_TITLES[type]
161
+
162
+ inner = if first_content.empty?
163
+ rest
164
+ else
165
+ "<p>#{first_content}</p>#{rest}"
166
+ end
167
+
168
+ %(<div class="admonition admonition-#{css_class}">\n<p class="admonition-title">#{title}</p>\n#{inner}</div>)
169
+ end
170
+ end
171
+
142
172
  def scope_footnote_ids(html)
143
173
  html.gsub(/(id="|href="#)(fn:|fnref:)(\w+)/) do
144
174
  "#{$1}#{$2}#{@slug}--#{$3}"
145
175
  end
146
176
  end
147
177
 
178
+ def extract_index_markers(html)
179
+ idx_count = 0
180
+ html.gsub(%r{<a\s+href="#index(?::([^"]*))?">(.*?)</a>}m) do
181
+ terms_str = $1
182
+ display_text = $2
183
+ anchor_id = "#{@slug}--idx-#{idx_count}"
184
+ idx_count += 1
185
+
186
+ terms = if terms_str && !terms_str.empty?
187
+ terms_str.split(",").map(&:strip)
188
+ else
189
+ [display_text.gsub(/<[^>]+>/, "")] # strip any HTML tags for the term
190
+ end
191
+
192
+ terms.each do |term|
193
+ @index_entries << IndexEntry.new(
194
+ term: term,
195
+ display_text: display_text,
196
+ chapter_slug: @slug,
197
+ anchor_id: anchor_id
198
+ )
199
+ end
200
+
201
+ %(<span id="#{anchor_id}">#{display_text}</span>)
202
+ end
203
+ end
204
+
148
205
  def rewrite_image_paths(html)
149
206
  html.gsub(/(<img\s[^>]*src=")([^"]+)(")/) do
150
207
  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,35 @@ 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
+
333
377
  == Previous/Next Navigation ==
334
378
 
335
379
  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.2.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.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - ligarb contributors