ligarb 0.8.2 → 0.9.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: bbc72fa10dd440a8c1da528db02f314735cae034beed2ae921ba3112caee7fbd
4
- data.tar.gz: 14a5b2b990c3bb507f02982c7d9b823f9525a7964b4bd37c69e243316788820a
3
+ metadata.gz: 10e90698ccef79745b7709265f5657df0354c86000ef85be7401b56cb443da0b
4
+ data.tar.gz: 5edf902c6f4d06b0fe923f5eb24372b33379bfb50296cadeec04ef308c0cc217
5
5
  SHA512:
6
- metadata.gz: 528b2af5e5039875052fba85d6765ce8489109bd726ad51732294746b04891bd6c7cd7927ebb98d09ccf97ac4dd3de6107111418691f855760f7e89db9e4f73b
7
- data.tar.gz: 6749c352bf7da3170bdbc9605422c8e7c4b59770f3f57dfa030bf96f07d47034e8f95c953815293e3267be477760e95379752cd2e082f550bbd2ca1a7251a1c2
6
+ metadata.gz: c2d201101b94c3b63405599c21e91c043ac98967fbf6b25035a0e959782c70fbbfa9948981caac86cdb9f25405ebe0e8f2d80c5e5bab6453912a62f1c212cf24
7
+ data.tar.gz: ed28454394317ade969c4fd8e5dd974b3f34e732208f7089a5f603160a9e9f5172c78d11ef04f872d3b455de18811a56501b9e69e6983ce4d38942bae93d77d4
@@ -0,0 +1,86 @@
1
+ // Build-time mermaid syntax checker for ligarb.
2
+ //
3
+ // Usage: node mermaid_check.mjs <path/to/mermaid.min.js>
4
+ // stdin: JSON array of {"id": <any>, "text": <mermaid source>}
5
+ // stdout: JSON array of {"id": <any>, "error": <message or null>}
6
+ //
7
+ // Loads the browser UMD bundle of mermaid in Node by stubbing just enough
8
+ // of the DOM (mermaid.parse() only parses; it never renders, but DOMPurify
9
+ // refuses to initialize without something that looks like a document).
10
+
11
+ const noop = () => {};
12
+
13
+ // Catch-all stub: any property access returns another stub, so the bundle's
14
+ // incidental DOM touches during initialization succeed silently.
15
+ function makeStub(name, overrides = {}) {
16
+ const target = function () {};
17
+ Object.assign(target, overrides);
18
+ return new Proxy(target, {
19
+ get(t, prop) {
20
+ if (prop === Symbol.toPrimitive) return () => "";
21
+ if (prop === "toString") return () => "";
22
+ if (typeof prop === "symbol") return undefined;
23
+ if (!(prop in t)) t[prop] = makeStub(name + "." + String(prop));
24
+ return t[prop];
25
+ },
26
+ set(t, prop, v) {
27
+ t[prop] = v;
28
+ return true;
29
+ },
30
+ apply() {
31
+ return makeStub(name + "()");
32
+ },
33
+ construct() {
34
+ return makeStub("new " + name);
35
+ },
36
+ });
37
+ }
38
+
39
+ globalThis.window = globalThis;
40
+ // nodeType 9 = DOCUMENT_NODE; DOMPurify checks it to decide it has a real DOM.
41
+ globalThis.document = makeStub("document", { nodeType: 9 });
42
+ globalThis.navigator = { userAgent: "node" };
43
+ globalThis.addEventListener = noop;
44
+ globalThis.location = { href: "http://localhost/", protocol: "http:" };
45
+ globalThis.Element = function Element() {};
46
+ globalThis.HTMLTemplateElement = function HTMLTemplateElement() {};
47
+ globalThis.Node = function Node() {};
48
+ globalThis.NodeFilter = { SHOW_ELEMENT: 1, SHOW_TEXT: 4, SHOW_COMMENT: 128 };
49
+ globalThis.NamedNodeMap = function NamedNodeMap() {};
50
+ globalThis.HTMLFormElement = function HTMLFormElement() {};
51
+ globalThis.DOMParser = function DOMParser() {
52
+ return makeStub("domparser");
53
+ };
54
+
55
+ const { readFileSync } = await import("fs");
56
+ const vm = await import("vm");
57
+
58
+ const mermaidPath = process.argv[2];
59
+ if (!mermaidPath) {
60
+ console.error("usage: node mermaid_check.mjs <mermaid.min.js>");
61
+ process.exit(2);
62
+ }
63
+
64
+ // The bundle starts with "use strict" + top-level `var`, so indirect eval
65
+ // would not create the global binding it expects; a classic script does.
66
+ vm.runInThisContext(readFileSync(mermaidPath, "utf8"), {
67
+ filename: "mermaid.min.js",
68
+ });
69
+
70
+ const mermaid = globalThis.mermaid;
71
+ if (!mermaid || typeof mermaid.parse !== "function") {
72
+ console.error("mermaid.parse is not available after loading the bundle");
73
+ process.exit(2);
74
+ }
75
+
76
+ const blocks = JSON.parse(readFileSync(0, "utf8"));
77
+ const results = [];
78
+ for (const block of blocks) {
79
+ try {
80
+ await mermaid.parse(block.text);
81
+ results.push({ id: block.id, error: null });
82
+ } catch (e) {
83
+ results.push({ id: block.id, error: String(e && e.message ? e.message : e) });
84
+ }
85
+ }
86
+ console.log(JSON.stringify(results));
data/assets/style.css CHANGED
@@ -515,7 +515,14 @@ mark.search-highlight {
515
515
  align-items: flex-start;
516
516
  }
517
517
 
518
- .theme-toggle {
518
+ .sidebar-header-actions {
519
+ display: flex;
520
+ gap: 0.35rem;
521
+ flex-shrink: 0;
522
+ }
523
+
524
+ .theme-toggle,
525
+ .sidebar-collapse {
519
526
  background: none;
520
527
  border: 1px solid var(--color-border);
521
528
  border-radius: 4px;
@@ -527,11 +534,30 @@ mark.search-highlight {
527
534
  flex-shrink: 0;
528
535
  }
529
536
 
530
- .theme-toggle:hover {
537
+ .theme-toggle:hover,
538
+ .sidebar-collapse:hover {
531
539
  color: var(--color-text);
532
540
  background: var(--color-sidebar-hover);
533
541
  }
534
542
 
543
+ /* Persistent collapse on wide screens: hide the sidebar and let content
544
+ reclaim the full width. The floating toggle reappears to bring it back.
545
+ Scoped to wide viewports so it never fights the off-canvas behavior. */
546
+ @media (min-width: 901px) {
547
+ .sidebar-collapsed .sidebar {
548
+ transform: translateX(-100%);
549
+ transition: transform 0.3s ease;
550
+ }
551
+
552
+ .sidebar-collapsed .content {
553
+ margin-left: 0;
554
+ }
555
+
556
+ .sidebar-collapsed .sidebar-toggle {
557
+ display: block;
558
+ }
559
+ }
560
+
535
561
  /* === Part / Appendix TOC === */
536
562
  .toc-part {
537
563
  margin-top: 0.5rem;
@@ -643,7 +669,9 @@ mark.search-highlight {
643
669
  }
644
670
 
645
671
  /* === Responsive === */
646
- @media (max-width: 768px) {
672
+ /* Off-canvas sidebar for narrow viewports. The 900px breakpoint covers iPad
673
+ portrait (810–834px), which would otherwise keep the fixed desktop sidebar. */
674
+ @media (max-width: 900px) {
647
675
  .sidebar {
648
676
  transform: translateX(-100%);
649
677
  transition: transform 0.3s ease;
@@ -657,6 +685,12 @@ mark.search-highlight {
657
685
  display: block;
658
686
  }
659
687
 
688
+ /* The in-header collapse button is only meaningful in the fixed desktop
689
+ layout; off-canvas already hides the sidebar via the floating toggle. */
690
+ .sidebar-collapse {
691
+ display: none;
692
+ }
693
+
660
694
  .content {
661
695
  margin-left: 0;
662
696
  padding: 2rem 1.5rem;
@@ -5,6 +5,7 @@ require_relative "config"
5
5
  require_relative "chapter"
6
6
  require_relative "template"
7
7
  require_relative "asset_manager"
8
+ require_relative "mermaid_checker"
8
9
 
9
10
  module Ligarb
10
11
  class Builder
@@ -29,6 +30,8 @@ module Ligarb
29
30
  assets.detect(all_chapters)
30
31
  assets.provision!
31
32
 
33
+ check_mermaid_syntax(all_chapters, assets, @config.output_path)
34
+
32
35
  index_entries = all_chapters.flat_map { |ch|
33
36
  ch.index_entries.map { |e|
34
37
  e.class.new(term: e.term, display_text: e.display_text,
@@ -77,6 +80,8 @@ module Ligarb
77
80
  assets.detect(all_lang_chapters)
78
81
  assets.provision!
79
82
 
83
+ check_mermaid_syntax(all_lang_chapters, assets, output_path)
84
+
80
85
  gr_config = langs.first && langs.first[:config]
81
86
  html = Template.new.render_multilang(langs: langs, assets: assets,
82
87
  hub_data: hub_data,
@@ -124,6 +129,16 @@ module Ligarb
124
129
  }
125
130
  end
126
131
 
132
+ # Validate mermaid block syntax with the downloaded mermaid.min.js under
133
+ # Node. Warns (with file:line) but never fails the build; skipped when
134
+ # Node is unavailable.
135
+ def check_mermaid_syntax(chapters, assets, output_path)
136
+ return unless assets.need?(:mermaid)
137
+
138
+ mermaid_js = File.join(output_path, "js", "mermaid.min.js")
139
+ MermaidChecker.check(chapters, mermaid_js)
140
+ end
141
+
127
142
  # StructNode mirrors Config::StructEntry but holds loaded Chapter objects
128
143
  StructNode = Struct.new(:type, :chapter, :children, keyword_init: true)
129
144
 
@@ -7,12 +7,14 @@ module Ligarb
7
7
  class Chapter
8
8
  class CrossReferenceError < StandardError; end
9
9
 
10
- attr_reader :title, :slug, :html, :headings, :number, :appendix_letter, :index_entries, :cite_entries
10
+ attr_reader :title, :slug, :html, :headings, :number, :appendix_letter, :index_entries, :cite_entries,
11
+ :path, :mermaid_blocks
11
12
  attr_accessor :part_title, :cover, :relative_path
12
13
 
13
14
  Heading = Struct.new(:level, :text, :id, :display_text, keyword_init: true)
14
15
  IndexEntry = Struct.new(:term, :display_text, :chapter_slug, :anchor_id, keyword_init: true)
15
16
  CiteEntry = Struct.new(:key, :display_text, :chapter_slug, :anchor_id, keyword_init: true)
17
+ MermaidBlock = Struct.new(:text, :line, keyword_init: true)
16
18
 
17
19
  def initialize(path, base_dir, slug_prefix: nil)
18
20
  @path = path
@@ -167,11 +169,14 @@ module Ligarb
167
169
  end
168
170
 
169
171
  def convert_special_code_blocks(html)
172
+ @mermaid_blocks = []
173
+ @mermaid_search_pos = 0
170
174
  html.gsub(%r{<pre><code class="language-(mermaid|math|functionplot)">(.*?)</code></pre>}m) do
171
175
  lang = $1
172
176
  raw = decode_entities($2)
173
177
  case lang
174
178
  when "mermaid"
179
+ @mermaid_blocks << MermaidBlock.new(text: raw, line: find_source_line(raw))
175
180
  escaped = raw.gsub("&", "&amp;").gsub("<", "&lt;").gsub(">", "&gt;")
176
181
  %(<div class="mermaid">\n#{raw}</div>) +
177
182
  %(<details class="mermaid-source"><summary>mermaid source</summary><pre>#{escaped}</pre></details>)
@@ -183,6 +188,21 @@ module Ligarb
183
188
  end
184
189
  end
185
190
 
191
+ # Best-effort: locate the first content line of a mermaid block in the
192
+ # Markdown source. Scans forward from the previous match so repeated
193
+ # blocks map to successive locations. Returns a 1-based line number or nil.
194
+ def find_source_line(block_text)
195
+ first = block_text.each_line.map(&:chomp).find { |l| !l.strip.empty? }
196
+ return nil unless first
197
+
198
+ lines = @source.lines
199
+ idx = (@mermaid_search_pos...lines.size).find { |i| lines[i].chomp == first }
200
+ return nil unless idx
201
+
202
+ @mermaid_search_pos = idx + 1
203
+ idx + 1
204
+ end
205
+
186
206
  def convert_inline_math(html)
187
207
  # Protect <pre>...</pre> and <code>...</code> from conversion
188
208
  placeholders = []
data/lib/ligarb/cli.rb CHANGED
@@ -19,25 +19,31 @@ module Ligarb
19
19
  Initializer.new(args.first).run
20
20
  when "setup-github-review"
21
21
  require_relative "github_review"
22
- directory = args.reject { |a| a.start_with?("--") }.first
23
- GithubReview.run(directory)
22
+ owner_idx = args.index("--owner") || args.index("--user")
23
+ owner = owner_idx ? args[owner_idx + 1] : nil
24
+ abort "Error: --owner requires a value (e.g. --owner my-org)" if owner_idx && (owner.nil? || owner.start_with?("--"))
25
+ positional = args.each_index.reject { |i| args[i].start_with?("--") || i == owner_idx&.+(1) }
26
+ directory = positional.map { |i| args[i] }.first
27
+ GithubReview.run(directory, owner: owner)
24
28
  when "serve"
25
- config_paths = args.reject { |a| a.start_with?("--") }
29
+ port, port_idx = parse_port(args)
30
+ host, host_idx = parse_host(args)
31
+ # Drop flags and the --port/--host values so they aren't mistaken for CONFIG paths.
32
+ value_indices = [port_idx, host_idx].compact.map { |i| i + 1 }
33
+ config_paths = args.each_index
34
+ .reject { |i| args[i].start_with?("--") || value_indices.include?(i) }
35
+ .map { |i| args[i] }
26
36
  config_paths = ["book.yml"] if config_paths.empty?
27
- port_idx = args.index("--port")
28
- port = port_idx ? args[port_idx + 1].to_i : 3000
29
- abort "Error: port must be 1-65535" unless (1..65535).include?(port)
30
37
  multi = args.include?("--multi")
31
38
  require_relative "server"
32
- Server.new(config_paths, port: port, multi: multi).start
39
+ Server.new(config_paths, port: port, host: host, multi: multi).start
33
40
  when "librarium"
34
41
  config_paths = Dir.glob("*/book.yml").sort
35
42
  abort "Error: no */book.yml found in current directory" if config_paths.empty?
36
- port_idx = args.index("--port")
37
- port = port_idx ? args[port_idx + 1].to_i : 3000
38
- abort "Error: port must be 1-65535" unless (1..65535).include?(port)
43
+ port, = parse_port(args)
44
+ host, = parse_host(args)
39
45
  require_relative "server"
40
- Server.new(config_paths, port: port, multi: true).start
46
+ Server.new(config_paths, port: port, host: host, multi: true).start
41
47
  when "write"
42
48
  require_relative "writer"
43
49
  begin
@@ -65,17 +71,48 @@ module Ligarb
65
71
  end
66
72
  end
67
73
 
74
+ # Parses `--port N` from args. Returns [port, flag_index]; port defaults to
75
+ # 3000 when --port is absent (flag_index is then nil). Aborts on a missing
76
+ # or out-of-range value.
77
+ def parse_port(args)
78
+ idx = args.index("--port")
79
+ return [3000, nil] unless idx
80
+
81
+ value = args[idx + 1]
82
+ abort "Error: --port requires a value (e.g. --port 8080)" if value.nil? || value.start_with?("--")
83
+ port = value.to_i
84
+ abort "Error: port must be 1-65535" unless (1..65535).include?(port)
85
+ [port, idx]
86
+ end
87
+
88
+ # Parses `--host ADDR` from args. Returns [host, flag_index]; host defaults
89
+ # to "127.0.0.1" (loopback only) when --host is absent (flag_index is then
90
+ # nil). Aborts on a missing value.
91
+ def parse_host(args)
92
+ idx = args.index("--host")
93
+ return ["127.0.0.1", nil] unless idx
94
+
95
+ value = args[idx + 1]
96
+ abort "Error: --host requires a value (e.g. --host 0.0.0.0)" if value.nil? || value.start_with?("--")
97
+ [value, idx]
98
+ end
99
+
68
100
  def print_usage
69
101
  puts <<~USAGE
70
102
  ligarb #{VERSION} - Generate a single-page HTML book from Markdown files
71
103
 
72
104
  Usage:
73
105
  ligarb init [DIRECTORY] Create a new book project
74
- ligarb setup-github-review [DIRECTORY]
106
+ ligarb setup-github-review [DIRECTORY] [--owner NAME]
75
107
  Set up (or update) GitHub Pages + review workflows
108
+ (--owner/--user seeds repository: owner when unset)
76
109
  ligarb build [CONFIG] Build the HTML book (default CONFIG: book.yml)
77
- ligarb serve [CONFIG] Serve the book with live reload and review UI
78
- ligarb librarium Serve all */book.yml as a multi-book library
110
+ ligarb serve [CONFIG] [--port N] [--host ADDR]
111
+ Serve the book with live reload and review UI
112
+ (--host ADDR binds the interface; default
113
+ 127.0.0.1. Use 0.0.0.0 to expose on the LAN)
114
+ ligarb librarium [--port N] [--host ADDR]
115
+ Serve all */book.yml as a multi-book library
79
116
  ligarb write [BRIEF] Generate a book with AI from brief.yml
80
117
  ligarb write --init [DIR] Create DIR/brief.yml template
81
118
  ligarb help Show detailed specification (for AI integration)
data/lib/ligarb/config.rb CHANGED
@@ -11,7 +11,9 @@ module Ligarb
11
11
  # config file that was directly passed to `ligarb build`.
12
12
  INHERITABLE_KEYS = %w[author language chapter_numbers style
13
13
  repository ai_generated footer bibliography
14
- github_review].freeze
14
+ github_review site_url].freeze
15
+ # `description` is intentionally not inheritable: it is usually
16
+ # language-specific, so each translation should set its own.
15
17
 
16
18
  # Represents a structural entry in the book
17
19
  StructEntry = Struct.new(:type, :path, :children, keyword_init: true)
@@ -26,7 +28,7 @@ module Ligarb
26
28
  attr_reader :title, :author, :language, :output_dir, :base_dir,
27
29
  :chapter_numbers, :structure, :style, :repository,
28
30
  :ai_generated, :footer, :bibliography, :sources,
29
- :translations, :github_review
31
+ :translations, :github_review, :description, :site_url
30
32
 
31
33
  def initialize(path, parent_data: nil)
32
34
  @base_dir = File.dirname(File.expand_path(path))
@@ -60,6 +62,8 @@ module Ligarb
60
62
  validate!(data)
61
63
 
62
64
  @title = data["title"]
65
+ @description = data.fetch("description", nil)
66
+ @site_url = data.fetch("site_url", nil)
63
67
  @author = data.fetch("author", "")
64
68
  @language = data.fetch("language", "en")
65
69
  @output_dir = data.fetch("output_dir", "build")
@@ -43,7 +43,7 @@ module Ligarb
43
43
  # feedback UI in book.yml, and prints the remaining manual-setup steps.
44
44
  # Safe to re-run to pull in updated templates (generated files are
45
45
  # overwritten; book.yml is not).
46
- def self.run(directory = nil)
46
+ def self.run(directory = nil, owner: nil)
47
47
  target = File.expand_path(directory || ".")
48
48
  unless File.exist?(File.join(target, "book.yml"))
49
49
  $stderr.puts "Error: book.yml not found in #{target}"
@@ -51,18 +51,21 @@ module Ligarb
51
51
  exit 1
52
52
  end
53
53
 
54
- reviewer = new(target)
54
+ reviewer = new(target, owner: owner)
55
55
  # book.yml edits must run BEFORE generate so the templates (SETUP.sh,
56
56
  # issue forms, README) are substituted with the resolved repository.
57
57
  repository = reviewer.ensure_repository_in_book_yml
58
+ site_url = reviewer.ensure_site_url_in_book_yml
58
59
  enabled = reviewer.enable_in_book_yml
59
60
  readme = reviewer.create_readme_if_absent
60
61
  result = reviewer.generate
61
- reviewer.print_notice(result, repository: repository, enabled: enabled, readme: readme)
62
+ reviewer.print_notice(result, repository: repository, site_url: site_url,
63
+ enabled: enabled, readme: readme)
62
64
  end
63
65
 
64
- def initialize(target)
66
+ def initialize(target, owner: nil)
65
67
  @target = File.expand_path(target)
68
+ @owner = owner
66
69
  end
67
70
 
68
71
  # Writes all template files into the project, substituting __OWNER__ /
@@ -110,23 +113,64 @@ module Ligarb
110
113
  end
111
114
 
112
115
  # Seeds a default `repository:` in book.yml when it has none, guessing
113
- # https://github.com/<os-user>/<dir-name>. This drives __OWNER__/__REPO__
114
- # substitution and the GH Pages link; the user edits it if the guess is
115
- # wrong. Returns :added, :present, or :unsupported. (@default_repository is
116
- # set to the guessed URL when :added, for the notice.)
116
+ # https://github.com/<owner>/<dir-name>. <owner> is the --owner/--user flag
117
+ # when given, else $USER. This drives __OWNER__/__REPO__ substitution and
118
+ # the GH Pages link; the user edits it if the guess is wrong. Returns
119
+ # :added, :present, or :unsupported. (@default_repository is set to the
120
+ # guessed URL when :added, for the notice.)
121
+ #
122
+ # Aborts if --owner was given but book.yml already has a `repository:`, since
123
+ # we never rewrite an existing repository — the user edits it themselves.
117
124
  def ensure_repository_in_book_yml
118
125
  book_yml = File.join(@target, "book.yml")
119
126
  data = YAML.safe_load_file(book_yml)
120
127
  return :unsupported unless data.is_a?(Hash)
121
- return :present if data.key?("repository")
128
+ if data.key?("repository")
129
+ if @owner
130
+ abort "Error: --owner was given but book.yml already has 'repository: #{data["repository"]}'.\n" \
131
+ "Edit 'repository:' in book.yml directly to change the owner, then re-run setup-github-review."
132
+ end
133
+ return :present
134
+ end
122
135
 
123
- user = ENV["USER"] || ENV["USERNAME"] || "your-github-account"
124
- @default_repository = "https://github.com/#{user}/#{File.basename(@target)}"
136
+ owner = @owner || ENV["USER"] || ENV["USERNAME"] || "your-github-account"
137
+ @default_repository = "https://github.com/#{owner}/#{File.basename(@target)}"
125
138
  content = File.read(book_yml).rstrip
126
139
  File.write(book_yml, %(#{content}\n\nrepository: "#{@default_repository}"\n))
127
140
  :added
128
141
  end
129
142
 
143
+ # Seeds a default `site_url:` in book.yml when it has none, deriving the
144
+ # GitHub Pages URL from `repository`. Drives og:url / canonical in the build
145
+ # output; the user edits it for custom domains or other hosting. Returns
146
+ # :added, :present, or :skipped (when no GitHub repository to derive from).
147
+ # (@default_site_url is set to the derived URL when :added, for the notice.)
148
+ def ensure_site_url_in_book_yml
149
+ book_yml = File.join(@target, "book.yml")
150
+ data = YAML.safe_load_file(book_yml)
151
+ return :skipped unless data.is_a?(Hash)
152
+ return :present if data.key?("site_url")
153
+
154
+ owner, repo = extract_owner_repo
155
+ return :skipped unless owner && repo
156
+
157
+ @default_site_url = pages_url(owner, repo)
158
+ content = File.read(book_yml).rstrip
159
+ File.write(book_yml, %(#{content}\n\nsite_url: "#{@default_site_url}"\n))
160
+ :added
161
+ end
162
+
163
+ # GitHub Pages URL for a repo. A repository named "<owner>.github.io" is the
164
+ # owner's user/org site served at the domain root; everything else is a
165
+ # project site served under /<repo>/.
166
+ def pages_url(owner, repo)
167
+ if repo.downcase == "#{owner.downcase}.github.io"
168
+ "https://#{owner}.github.io/"
169
+ else
170
+ "https://#{owner}.github.io/#{repo}/"
171
+ end
172
+ end
173
+
130
174
  # Creates a project README.md that links to the published GitHub Pages site,
131
175
  # but only when one does not already exist (the reader's own README is never
132
176
  # overwritten). Returns :created or :present.
@@ -135,7 +179,7 @@ module Ligarb
135
179
  return :present if File.exist?(readme)
136
180
 
137
181
  owner, repo = extract_owner_repo
138
- pages = owner && repo ? "https://#{owner}.github.io/#{repo}/" : "https://__OWNER__.github.io/__REPO__/"
182
+ pages = owner && repo ? pages_url(owner, repo) : "https://__OWNER__.github.io/__REPO__/"
139
183
  issues = owner && repo ? "https://github.com/#{owner}/#{repo}/issues/new?template=book-feedback.yml" \
140
184
  : "https://github.com/__OWNER__/__REPO__/issues/new?template=book-feedback.yml"
141
185
  title = book_title.to_s.empty? ? "Book" : book_title
@@ -164,7 +208,7 @@ module Ligarb
164
208
  :created
165
209
  end
166
210
 
167
- def print_notice(result, repository:, enabled:, readme:)
211
+ def print_notice(result, repository:, site_url:, enabled:, readme:)
168
212
  puts "Set up GitHub review scaffolding in #{@target}:"
169
213
  result.created.each { |path| puts " created #{path}" }
170
214
  result.updated.each { |path| puts " updated #{path}" }
@@ -172,6 +216,9 @@ module Ligarb
172
216
  case repository
173
217
  when :added then puts " updated book.yml (repository: #{@default_repository})"
174
218
  end
219
+ case site_url
220
+ when :added then puts " updated book.yml (site_url: #{@default_site_url})"
221
+ end
175
222
  case enabled
176
223
  when :added then puts " updated book.yml (github_review.enabled: true)"
177
224
  when :present then puts " kept book.yml github_review setting"
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "open3"
5
+
6
+ module Ligarb
7
+ # Build-time syntax check for mermaid blocks. Runs the downloaded
8
+ # mermaid.min.js under Node (assets/mermaid_check.mjs) and calls
9
+ # mermaid.parse() on each block. Reports syntax errors as warnings with
10
+ # file:line locations; never fails the build (the generated HTML already
11
+ # shows an error box for broken diagrams at view time).
12
+ class MermaidChecker
13
+ HARNESS = File.expand_path("../../assets/mermaid_check.mjs", __dir__)
14
+
15
+ # chapters: Chapter objects (mermaid_blocks may be empty)
16
+ # mermaid_js: path to the provisioned mermaid.min.js
17
+ # Returns the number of blocks with syntax errors.
18
+ def self.check(chapters, mermaid_js)
19
+ new(chapters, mermaid_js).check
20
+ end
21
+
22
+ def initialize(chapters, mermaid_js)
23
+ @chapters = chapters
24
+ @mermaid_js = mermaid_js
25
+ end
26
+
27
+ def check
28
+ blocks = collect_blocks
29
+ return 0 if blocks.empty?
30
+
31
+ unless File.exist?(@mermaid_js)
32
+ warn "Warning: #{@mermaid_js} not found; skipping mermaid syntax check"
33
+ return 0
34
+ end
35
+
36
+ results = run_harness(blocks)
37
+ return 0 unless results
38
+
39
+ error_count = 0
40
+ results.each do |result|
41
+ error = result["error"]
42
+ next unless error
43
+
44
+ error_count += 1
45
+ block = blocks[result["id"]]
46
+ warn "Warning: mermaid syntax error in #{block[:location]}"
47
+ error.each_line { |l| warn " #{l.chomp}" }
48
+ end
49
+ error_count
50
+ end
51
+
52
+ private
53
+
54
+ def collect_blocks
55
+ blocks = []
56
+ @chapters.each do |ch|
57
+ ch.mermaid_blocks.each do |block|
58
+ location = block.line ? "#{ch.path}:#{block.line}" : ch.path
59
+ blocks << { id: blocks.size, text: block.text, location: location }
60
+ end
61
+ end
62
+ blocks
63
+ end
64
+
65
+ def run_harness(blocks)
66
+ input = JSON.generate(blocks.map { |b| { id: b[:id], text: b[:text] } })
67
+ stdout, stderr, status = Open3.capture3("node", HARNESS, @mermaid_js, stdin_data: input)
68
+ unless status.success?
69
+ warn "Warning: mermaid syntax check failed to run (node exited #{status.exitstatus}); skipping"
70
+ stderr.each_line.first(3).each { |l| warn " #{l.chomp}" } if stderr && !stderr.empty?
71
+ return nil
72
+ end
73
+ JSON.parse(stdout)
74
+ rescue Errno::ENOENT
75
+ warn "Warning: node not found; skipping mermaid syntax check"
76
+ nil
77
+ rescue JSON::ParserError
78
+ warn "Warning: mermaid syntax check produced unexpected output; skipping"
79
+ nil
80
+ end
81
+ end
82
+ end
data/lib/ligarb/server.rb CHANGED
@@ -14,8 +14,9 @@ module Ligarb
14
14
 
15
15
  BookEntry = Struct.new(:slug, :config, :config_path, :build_dir, :store, :claude, keyword_init: true)
16
16
 
17
- def initialize(config_paths, port: 3000, multi: false)
17
+ def initialize(config_paths, port: 3000, host: "127.0.0.1", multi: false)
18
18
  @port = port
19
+ @host = host
19
20
  @assets_dir = File.join(File.dirname(__FILE__), "..", "..", "assets")
20
21
  @sse_clients = [] # [[slug, queue], ...]
21
22
  @sse_mutex = Mutex.new
@@ -51,9 +52,11 @@ module Ligarb
51
52
  Builder.new(book.config_path).build
52
53
  end
53
54
 
55
+ warn_if_exposed
56
+
54
57
  server = WEBrick::HTTPServer.new(
55
58
  Port: @port,
56
- BindAddress: "127.0.0.1",
59
+ BindAddress: @host,
57
60
  Logger: WEBrick::Log.new($stderr, WEBrick::Log::INFO),
58
61
  AccessLog: [[File.open(File::NULL, "w"), WEBrick::AccessLog::COMMON_LOG_FORMAT]],
59
62
  RequestBodyMaxSize: 50 * 1024 * 1024
@@ -67,12 +70,13 @@ module Ligarb
67
70
 
68
71
  @books.each_value { |book| start_build_watcher(book) }
69
72
 
73
+ base_url = "http://#{display_host}:#{@port}"
70
74
  if @multi
71
- puts "Serving #{@books.size} books at http://localhost:#{@port}"
75
+ puts "Serving #{@books.size} books at #{base_url}"
72
76
  @books.each_value { |b| puts " /#{b.slug}/ — #{b.config.title}" }
73
77
  else
74
78
  book = @books.values.first
75
- puts "Serving #{book.config.title} at http://localhost:#{@port}"
79
+ puts "Serving #{book.config.title} at #{base_url}"
76
80
  puts " Build directory: #{book.build_dir}"
77
81
  end
78
82
  puts " Press Ctrl+C to stop"
@@ -82,6 +86,26 @@ module Ligarb
82
86
 
83
87
  private
84
88
 
89
+ LOOPBACK_HOSTS = %w[127.0.0.1 ::1 localhost].freeze
90
+
91
+ # Host to print in URLs. A wildcard or unspecified bind address is reachable
92
+ # via localhost, so show that; otherwise show the concrete bind address.
93
+ def display_host
94
+ return "localhost" if LOOPBACK_HOSTS.include?(@host) || ["0.0.0.0", "::", ""].include?(@host)
95
+ @host
96
+ end
97
+
98
+ # When binding beyond loopback, the review/feedback APIs (which can run
99
+ # Claude and write files) become reachable from the network. Warn so this
100
+ # is never a surprise.
101
+ def warn_if_exposed
102
+ return if LOOPBACK_HOSTS.include?(@host)
103
+
104
+ warn "Warning: binding to #{@host} exposes ligarb serve on the network."
105
+ warn " The review/feedback APIs (file writes, Claude runs) will be reachable by other hosts."
106
+ warn " Only do this on a trusted network."
107
+ end
108
+
85
109
  # ── Operation Queue ──
86
110
 
87
111
  def operation_worker
@@ -701,29 +725,41 @@ module Ligarb
701
725
 
702
726
  # ── API routing ──
703
727
 
728
+ # Same-origin check for state-changing requests. The request's Origin (or
729
+ # Referer) must match the Host the browser actually connected to, so this
730
+ # works for any bind address (loopback, a LAN IP, a hostname, 0.0.0.0)
731
+ # without trusting the bind address itself. A cross-site request carries the
732
+ # attacker's Origin while Host stays ours, so it is rejected.
704
733
  def csrf_safe?(req)
705
734
  return true if req.request_method == "GET"
706
735
 
736
+ host = req["Host"]
737
+ return false if host.nil? || host.empty?
738
+
707
739
  origin = req["Origin"]
708
- if origin && !origin.empty?
709
- return origin == "http://localhost:#{@port}" ||
710
- origin == "http://127.0.0.1:#{@port}"
711
- end
740
+ return same_authority?(origin, host) if origin && !origin.empty?
712
741
 
713
742
  referer = req["Referer"]
714
- if referer && !referer.empty?
715
- uri = URI.parse(referer) rescue nil
716
- return uri && %w[localhost 127.0.0.1].include?(uri.host) && uri.port == @port
717
- end
743
+ return same_authority?(referer, host) if referer && !referer.empty?
718
744
 
719
745
  false
720
746
  end
721
747
 
748
+ # True when the URL's authority (host[:port]) equals the Host header.
749
+ def same_authority?(url, host_header)
750
+ uri = URI.parse(url) rescue nil
751
+ return false unless uri&.host
752
+
753
+ candidates = [uri.host]
754
+ candidates << "#{uri.host}:#{uri.port}" if uri.port
755
+ candidates.include?(host_header)
756
+ end
757
+
722
758
  def handle_api(req, res)
723
759
  unless csrf_safe?(req)
724
760
  res.status = 403
725
761
  res["Content-Type"] = "application/json; charset=utf-8"
726
- res.body = JSON.generate({ error: "CSRF check failed: request must originate from localhost" })
762
+ res.body = JSON.generate({ error: "CSRF check failed: request must be same-origin" })
727
763
  return
728
764
  end
729
765
 
@@ -39,6 +39,9 @@ module Ligarb
39
39
  b.local_variable_set(:multilang, false)
40
40
  b.local_variable_set(:langs, [])
41
41
  b.local_variable_set(:feedback, load_feedback(github_review))
42
+ b.local_variable_set(:og_description, og_description(config, chapters))
43
+ b.local_variable_set(:og_locale, og_locale(config.language))
44
+ b.local_variable_set(:og_url, og_url(config))
42
45
 
43
46
  ERB.new(template, trim_mode: "-").result(b)
44
47
  end
@@ -93,6 +96,9 @@ module Ligarb
93
96
  b.local_variable_set(:multilang, true)
94
97
  b.local_variable_set(:langs, lang_data)
95
98
  b.local_variable_set(:feedback, load_feedback(github_review))
99
+ b.local_variable_set(:og_description, og_description(first_config, first[:chapters]))
100
+ b.local_variable_set(:og_locale, og_locale(first_config.language))
101
+ b.local_variable_set(:og_url, og_url(first_config))
96
102
 
97
103
  ERB.new(template, trim_mode: "-").result(b)
98
104
  end
@@ -132,6 +138,51 @@ module Ligarb
132
138
  ERB::Util.html_escape(s.to_s)
133
139
  end
134
140
 
141
+ # og:locale wants ll_CC; ligarb only knows the bare language code, so map
142
+ # the common ones and fall back to the raw value for anything else.
143
+ OG_LOCALES = { "ja" => "ja_JP", "en" => "en_US" }.freeze
144
+
145
+ def og_locale(language)
146
+ OG_LOCALES.fetch(language, language)
147
+ end
148
+
149
+ # Canonical URL for og:url, taken verbatim from `site_url` (the published
150
+ # root of this build). Returns nil when unset so the tag is omitted — a
151
+ # wrong canonical URL is worse than none.
152
+ def og_url(config)
153
+ url = config.site_url.to_s.strip
154
+ url.empty? ? nil : url
155
+ end
156
+
157
+ # Resolve the OGP description: the configured `description`, or, failing
158
+ # that, the first real paragraph of prose from the cover (or first) chapter.
159
+ # Returns nil when nothing usable is found.
160
+ def og_description(config, chapters)
161
+ explicit = config.description.to_s.strip
162
+ return explicit unless explicit.empty?
163
+
164
+ auto_description(chapters)
165
+ end
166
+
167
+ DESCRIPTION_LIMIT = 200
168
+
169
+ def auto_description(chapters)
170
+ chapter = chapters.find(&:cover?) || chapters.first
171
+ return nil unless chapter
172
+
173
+ # First <p> with real prose (tags stripped, whitespace collapsed).
174
+ # Image-only paragraphs (e.g. a cover logo) strip to empty and are skipped.
175
+ chapter.html.scan(%r{<p[^>]*>(.*?)</p>}m) do |(inner)|
176
+ text = inner.gsub(/<[^>]+>/, "").gsub(/\s+/, " ").strip
177
+ text = text.gsub("&amp;", "&").gsub("&lt;", "<").gsub("&gt;", ">").gsub("&quot;", '"').gsub("&#39;", "'")
178
+ next if text.empty?
179
+
180
+ return text.length > DESCRIPTION_LIMIT ? "#{text[0, DESCRIPTION_LIMIT - 1].rstrip}…" : text
181
+ end
182
+
183
+ nil
184
+ end
185
+
135
186
  # Build a sorted tree structure for the index.
136
187
  # Returns: { "A" => [ { term: "Algorithm", refs: [...] },
137
188
  # { term: "Array", refs: [...], children: [ { term: "sort", refs: [...] } ] } ],
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Ligarb
4
- VERSION = "0.8.2"
4
+ VERSION = "0.9.0"
5
5
  end
@@ -4,6 +4,22 @@
4
4
  <meta charset="UTF-8">
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
6
  <title><%= h(title) %></title>
7
+ <meta property="og:type" content="book">
8
+ <meta property="og:title" content="<%= h(title) %>">
9
+ <meta property="og:site_name" content="<%= h(title) %>">
10
+ <meta property="og:locale" content="<%= h(og_locale) %>">
11
+ <%- if og_url -%>
12
+ <meta property="og:url" content="<%= h(og_url) %>">
13
+ <link rel="canonical" href="<%= h(og_url) %>">
14
+ <%- end -%>
15
+ <%- if og_description && !og_description.empty? -%>
16
+ <meta name="description" content="<%= h(og_description) %>">
17
+ <meta property="og:description" content="<%= h(og_description) %>">
18
+ <%- end -%>
19
+ <meta name="twitter:card" content="summary">
20
+ <%- if author && !author.to_s.empty? -%>
21
+ <meta property="book:author" content="<%= h(author) %>">
22
+ <%- end -%>
7
23
  <%- if ai_generated -%>
8
24
  <meta name="robots" content="noindex, nofollow, noarchive">
9
25
  <meta name="robots" content="noai, noimageai">
@@ -50,7 +66,10 @@
50
66
  <h1 class="book-title"><%= h(title) %></h1>
51
67
  <%- end -%>
52
68
  <%- end -%>
69
+ <div class="sidebar-header-actions">
53
70
  <button class="theme-toggle" id="theme-toggle" aria-label="Toggle dark mode" title="Toggle dark mode">&#9790;</button>
71
+ <button class="sidebar-collapse" id="sidebar-collapse" aria-label="Collapse sidebar" title="Collapse sidebar">&#171;</button>
72
+ </div>
54
73
  </div>
55
74
  <%- if multilang -%>
56
75
  <%- langs.each do |ld| -%>
@@ -256,7 +275,7 @@
256
275
  <%- if multilang -%>
257
276
  <%- langs.each do |ld| -%>
258
277
  <%- ld[:chapters].select(&:cover?).each do |chapter| -%>
259
- <section class="chapter cover-page" id="chapter-<%= chapter.slug %>" data-lang="<%= ld[:lang] %>" style="display: none;">
278
+ <section class="chapter cover-page" id="chapter-<%= chapter.slug %>" data-lang="<%= ld[:lang] %>"<%= src_attrs(chapter, feedback) %> style="display: none;">
260
279
  <%= chapter.html %>
261
280
  </section>
262
281
  <%- end -%>
@@ -373,7 +392,7 @@
373
392
  <%- end -%>
374
393
  <%- else -%>
375
394
  <%- chapters.select(&:cover?).each do |chapter| -%>
376
- <section class="chapter cover-page" id="chapter-<%= chapter.slug %>" style="display: none;">
395
+ <section class="chapter cover-page" id="chapter-<%= chapter.slug %>"<%= src_attrs(chapter, feedback) %> style="display: none;">
377
396
  <%= chapter.html %>
378
397
  </section>
379
398
  <%- end -%>
@@ -1041,18 +1060,44 @@
1041
1060
  }
1042
1061
  }
1043
1062
 
1044
- // Sidebar toggle for mobile
1063
+ // Sidebar show/hide. Two behaviors share one breakpoint (kept in sync with
1064
+ // the 900px media query in style.css):
1065
+ // - narrow (iPad portrait, phones): the sidebar is off-canvas; the floating
1066
+ // toggle slides it in/out as an overlay.
1067
+ // - wide (desktop): the sidebar is fixed; the in-header « button collapses
1068
+ // it so the content reclaims full width. The collapsed state persists.
1045
1069
  var toggle = document.getElementById('sidebar-toggle');
1070
+ var collapseBtn = document.getElementById('sidebar-collapse');
1046
1071
  var sidebar = document.getElementById('sidebar');
1072
+ var narrow = window.matchMedia('(max-width: 900px)');
1073
+
1074
+ function setCollapsed(state) {
1075
+ document.body.classList.toggle('sidebar-collapsed', state);
1076
+ localStorage.setItem('ligarb-sidebar-collapsed', state ? '1' : '0');
1077
+ }
1078
+
1079
+ if (localStorage.getItem('ligarb-sidebar-collapsed') === '1') {
1080
+ document.body.classList.add('sidebar-collapsed');
1081
+ }
1082
+
1047
1083
  toggle.addEventListener('click', function() {
1048
- sidebar.classList.toggle('open');
1084
+ if (narrow.matches) {
1085
+ sidebar.classList.toggle('open'); // off-canvas overlay
1086
+ } else {
1087
+ setCollapsed(false); // wide: the floating toggle only
1088
+ // shows when collapsed, so reopen
1089
+ }
1090
+ });
1091
+
1092
+ collapseBtn.addEventListener('click', function() {
1093
+ setCollapsed(true);
1049
1094
  });
1050
1095
 
1051
- // Close sidebar when clicking a link on mobile
1096
+ // Close the off-canvas sidebar after tapping a link (narrow only)
1052
1097
  var tocLinks = document.querySelectorAll('.toc a');
1053
1098
  tocLinks.forEach(function(link) {
1054
1099
  link.addEventListener('click', function() {
1055
- if (window.innerWidth <= 768) {
1100
+ if (narrow.matches) {
1056
1101
  sidebar.classList.remove('open');
1057
1102
  }
1058
1103
  });
@@ -1,7 +1,8 @@
1
1
  name: Build and deploy book
2
2
 
3
3
  # master に push すると ligarb で本をビルドし、GitHub Pages に公開する。
4
- # 事前設定: リポジトリ Settings > Pages > Source "GitHub Actions" にしておくこと。
4
+ # Pages configure-pages enablement: true で自動有効化されるため事前設定は不要。
5
+ # 手動で設定する場合は Settings > Pages > Source を "GitHub Actions" にする。
5
6
 
6
7
  on:
7
8
  push:
@@ -31,6 +32,9 @@ jobs:
31
32
  - name: Build book
32
33
  run: ligarb build # build/index.html を生成
33
34
  - uses: actions/configure-pages@v5
35
+ with:
36
+ # Pages が未設定でもワークフロー側で有効化する(初回 push のレースを防ぐ)。
37
+ enablement: true
34
38
  - uses: actions/upload-pages-artifact@v3
35
39
  with:
36
40
  path: build
@@ -108,7 +108,9 @@ gh を使わない場合や、設定の意味を確認したいときの参照
108
108
 
109
109
  ### GitHub Pages(手順 C-4 の代替)
110
110
 
111
- - Settings > Pages > Source を **"GitHub Actions"** にする。
111
+ - 基本的には設定不要。`deploy-book.yml` が初回実行時に Pages を自動で有効化する
112
+ (`configure-pages` の `enablement: true`)。
113
+ - 手動で設定する場合は Settings > Pages > Source を **"GitHub Actions"** にする。
112
114
 
113
115
  ### Actions の権限(手順 C-5 の代替)
114
116
 
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.8.2
4
+ version: 0.9.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - ligarb contributors
@@ -102,6 +102,7 @@ extra_rdoc_files: []
102
102
  files:
103
103
  - assets/feedback.css
104
104
  - assets/feedback.js
105
+ - assets/mermaid_check.mjs
105
106
  - assets/review.css
106
107
  - assets/review.js
107
108
  - assets/serve.js
@@ -116,6 +117,7 @@ files:
116
117
  - lib/ligarb/github_review.rb
117
118
  - lib/ligarb/initializer.rb
118
119
  - lib/ligarb/inotify.rb
120
+ - lib/ligarb/mermaid_checker.rb
119
121
  - lib/ligarb/review_store.rb
120
122
  - lib/ligarb/server.rb
121
123
  - lib/ligarb/template.rb