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 +4 -4
- data/assets/mermaid_check.mjs +86 -0
- data/assets/style.css +37 -3
- data/lib/ligarb/builder.rb +15 -0
- data/lib/ligarb/chapter.rb +21 -1
- data/lib/ligarb/cli.rb +51 -14
- data/lib/ligarb/config.rb +6 -2
- data/lib/ligarb/github_review.rb +60 -13
- data/lib/ligarb/mermaid_checker.rb +82 -0
- data/lib/ligarb/server.rb +49 -13
- data/lib/ligarb/template.rb +51 -0
- data/lib/ligarb/version.rb +1 -1
- data/templates/book.html.erb +51 -6
- data/templates/github_review/.github/workflows/deploy-book.yml +5 -1
- data/templates/github_review/SETUP.md +3 -1
- metadata +3 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 10e90698ccef79745b7709265f5657df0354c86000ef85be7401b56cb443da0b
|
|
4
|
+
data.tar.gz: 5edf902c6f4d06b0fe923f5eb24372b33379bfb50296cadeec04ef308c0cc217
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
.
|
|
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
|
-
|
|
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;
|
data/lib/ligarb/builder.rb
CHANGED
|
@@ -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
|
|
data/lib/ligarb/chapter.rb
CHANGED
|
@@ -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("&", "&").gsub("<", "<").gsub(">", ">")
|
|
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
|
-
|
|
23
|
-
|
|
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
|
-
|
|
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
|
-
|
|
37
|
-
|
|
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]
|
|
78
|
-
|
|
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")
|
data/lib/ligarb/github_review.rb
CHANGED
|
@@ -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,
|
|
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/<
|
|
114
|
-
#
|
|
115
|
-
#
|
|
116
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
124
|
-
@default_repository = "https://github.com/#{
|
|
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 ?
|
|
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:
|
|
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
|
|
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
|
|
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
|
|
762
|
+
res.body = JSON.generate({ error: "CSRF check failed: request must be same-origin" })
|
|
727
763
|
return
|
|
728
764
|
end
|
|
729
765
|
|
data/lib/ligarb/template.rb
CHANGED
|
@@ -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("&", "&").gsub("<", "<").gsub(">", ">").gsub(""", '"').gsub("'", "'")
|
|
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: [...] } ] } ],
|
data/lib/ligarb/version.rb
CHANGED
data/templates/book.html.erb
CHANGED
|
@@ -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">☾</button>
|
|
71
|
+
<button class="sidebar-collapse" id="sidebar-collapse" aria-label="Collapse sidebar" title="Collapse sidebar">«</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
|
|
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
|
-
|
|
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
|
|
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 (
|
|
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
|
-
#
|
|
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
|
-
-
|
|
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.
|
|
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
|