ligarb 0.6.0 → 0.8.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.
data/lib/ligarb/config.rb CHANGED
@@ -6,20 +6,57 @@ module Ligarb
6
6
  class Config
7
7
  REQUIRED_KEYS = %w[title chapters].freeze
8
8
 
9
+ # Keys that can be inherited from a parent (translations hub) config.
10
+ # output_dir is intentionally excluded — it always comes from the
11
+ # config file that was directly passed to `ligarb build`.
12
+ INHERITABLE_KEYS = %w[author language chapter_numbers style
13
+ repository ai_generated footer bibliography
14
+ github_review].freeze
15
+
9
16
  # Represents a structural entry in the book
10
17
  StructEntry = Struct.new(:type, :path, :children, keyword_init: true)
11
18
  # type: :chapter, :part, or :appendix_group
12
19
  # path: markdown file path (for :chapter and :part), nil for :appendix_group
13
20
  # children: array of StructEntry (for :part and :appendix_group)
14
21
 
22
+ # Represents a translation entry
23
+ # output_path: absolute path to the translation's build directory (for link generation)
24
+ TranslationEntry = Struct.new(:lang, :config_path, :title, :language, :output_path, keyword_init: true)
25
+
15
26
  attr_reader :title, :author, :language, :output_dir, :base_dir,
16
27
  :chapter_numbers, :structure, :style, :repository,
17
- :ai_generated, :footer, :bibliography, :sources
28
+ :ai_generated, :footer, :bibliography, :sources,
29
+ :translations, :github_review
18
30
 
19
- def initialize(path)
31
+ def initialize(path, parent_data: nil)
20
32
  @base_dir = File.dirname(File.expand_path(path))
21
33
  data = YAML.safe_load_file(path)
22
34
 
35
+ # If this is a translations hub (has translations but no chapters), skip normal validation
36
+ if data.is_a?(Hash) && data.key?("translations") && !data.key?("chapters")
37
+ @translations_hub = true
38
+ @translations_data = data
39
+ load_translations_hub(data)
40
+ return
41
+ end
42
+
43
+ # Load inherit file if specified (for standalone builds of child configs)
44
+ if !parent_data && data.is_a?(Hash) && data.key?("inherit")
45
+ inherit_path = File.expand_path(data["inherit"], @base_dir)
46
+ if File.exist?(inherit_path)
47
+ parent_data = YAML.safe_load_file(inherit_path)
48
+ else
49
+ abort "Error: inherit config not found: #{inherit_path}"
50
+ end
51
+ end
52
+
53
+ # Merge parent (inheritable) keys as defaults
54
+ if parent_data
55
+ INHERITABLE_KEYS.each do |key|
56
+ data[key] = parent_data[key] if parent_data.key?(key) && !data.key?(key)
57
+ end
58
+ end
59
+
23
60
  validate!(data)
24
61
 
25
62
  @title = data["title"]
@@ -32,8 +69,16 @@ module Ligarb
32
69
  @ai_generated = data.fetch("ai_generated", false)
33
70
  @footer = data.fetch("footer", nil)
34
71
  @bibliography = data.fetch("bibliography", nil)
72
+ @github_review = data.fetch("github_review", nil)
35
73
  @sources = parse_sources(data.fetch("sources", []))
36
74
  @structure = parse_structure(data["chapters"])
75
+ @translations = []
76
+
77
+ load_translations(data, path) if data.key?("translations")
78
+ end
79
+
80
+ def translations_hub?
81
+ @translations_hub == true
37
82
  end
38
83
 
39
84
  def output_path
@@ -52,6 +97,23 @@ module Ligarb
52
97
  @language == "ja" ? "付録" : "Appendix"
53
98
  end
54
99
 
100
+ # Whether the reader's "Report as issue" feedback UI is requested in book.yml.
101
+ # This reflects intent only; actual injection also requires `repository`
102
+ # (the issues/new base) — the builder warns and skips when it is missing.
103
+ def github_review_enabled?
104
+ @github_review.is_a?(Hash) && @github_review.fetch("enabled", false) == true
105
+ end
106
+
107
+ def github_review_issue_template
108
+ tmpl = @github_review.is_a?(Hash) ? @github_review["issue_template"] : nil
109
+ tmpl && !tmpl.to_s.empty? ? tmpl.to_s : "book-feedback.yml"
110
+ end
111
+
112
+ def github_review_labels
113
+ labels = @github_review.is_a?(Hash) ? @github_review["labels"] : nil
114
+ Array(labels || ["feedback"]).map(&:to_s)
115
+ end
116
+
55
117
  def effective_footer
56
118
  return @footer if @footer
57
119
  return nil unless @ai_generated
@@ -137,6 +199,66 @@ module Ligarb
137
199
  collect_chapter_paths(entries)
138
200
  end
139
201
 
202
+ def load_translations(data, config_path)
203
+ trans = data["translations"]
204
+ return unless trans.is_a?(Hash)
205
+
206
+ trans.each do |lang, rel_path|
207
+ abs_path = File.expand_path(rel_path, @base_dir)
208
+ unless File.exist?(abs_path)
209
+ abort "Error: translation config not found: #{abs_path}"
210
+ end
211
+ child_data = YAML.safe_load_file(abs_path)
212
+ # Merge parent keys for output_dir resolution
213
+ INHERITABLE_KEYS.each do |key|
214
+ child_data[key] = data[key] if data.key?(key) && !child_data.key?(key)
215
+ end
216
+ child_title = child_data["title"] || @title
217
+ child_lang = child_data.fetch("language", lang)
218
+ child_base = File.dirname(abs_path)
219
+ child_output = File.join(child_base, child_data.fetch("output_dir", "build"))
220
+ @translations << TranslationEntry.new(
221
+ lang: lang,
222
+ config_path: abs_path,
223
+ title: child_title,
224
+ language: child_lang,
225
+ output_path: child_output
226
+ )
227
+ end
228
+ end
229
+
230
+ def load_translations_hub(data)
231
+ @title = data.fetch("title", "")
232
+ @translations = []
233
+
234
+ trans = data["translations"]
235
+ return unless trans.is_a?(Hash)
236
+
237
+ trans.each do |lang, rel_path|
238
+ abs_path = File.expand_path(rel_path, @base_dir)
239
+ unless File.exist?(abs_path)
240
+ abort "Error: translation config not found: #{abs_path}"
241
+ end
242
+ child_data = YAML.safe_load_file(abs_path)
243
+ # Merge inheritable keys from hub as defaults
244
+ merged = child_data.dup
245
+ INHERITABLE_KEYS.each do |key|
246
+ merged[key] = data[key] if data.key?(key) && !merged.key?(key)
247
+ end
248
+ child_title = merged["title"] || @title
249
+ child_lang = merged.fetch("language", lang)
250
+ child_base = File.dirname(abs_path)
251
+ child_output = File.join(child_base, merged.fetch("output_dir", "build"))
252
+ @translations << TranslationEntry.new(
253
+ lang: lang,
254
+ config_path: abs_path,
255
+ title: child_title,
256
+ language: child_lang,
257
+ output_path: child_output
258
+ )
259
+ end
260
+ end
261
+
140
262
  def validate!(data)
141
263
  unless data.is_a?(Hash)
142
264
  abort "Error: book.yml must be a YAML mapping"
@@ -151,6 +273,10 @@ module Ligarb
151
273
  unless data["chapters"].is_a?(Array) && !data["chapters"].empty?
152
274
  abort "Error: 'chapters' must be a non-empty array"
153
275
  end
276
+
277
+ if data.key?("github_review") && !data["github_review"].is_a?(Hash)
278
+ abort "Error: 'github_review' must be a mapping (e.g. { enabled: true })"
279
+ end
154
280
  end
155
281
  end
156
282
  end
@@ -0,0 +1,245 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "yaml"
5
+
6
+ module Ligarb
7
+ # Sets up the GitHub-based review scaffolding (.github/ + SETUP.md) in a
8
+ # project — the `ligarb setup-github-review` command. This is pure file
9
+ # copying: ligarb never calls Claude or GitHub at runtime. The templates are
10
+ # classified into layers so a future option could split them apart:
11
+ # - generic layer : works without Claude (Pages deploy, build check, forms)
12
+ # - claude layer : opt-in Claude integration (issue/PR handlers, SETUP.md)
13
+ #
14
+ # Re-running OVERWRITES the generated files so a project can follow upstream
15
+ # template changes (e.g. after a ligarb upgrade). The user's own book.yml is
16
+ # never overwritten. Since projects are git repos, `git diff` is the safety
17
+ # net for reviewing/reverting changes after a re-sync.
18
+ class GithubReview
19
+ TEMPLATE_DIR = File.expand_path("../../templates/github_review", __dir__)
20
+
21
+ # Generic layer: no Claude dependency.
22
+ GENERIC_FILES = %w[
23
+ .github/workflows/deploy-book.yml
24
+ .github/workflows/build-check.yml
25
+ .github/ISSUE_TEMPLATE/book-feedback.yml
26
+ .github/ISSUE_TEMPLATE/config.yml
27
+ ].freeze
28
+
29
+ # Claude integration layer: opt-in.
30
+ CLAUDE_FILES = %w[
31
+ .github/workflows/claude-feedback.yml
32
+ .github/workflows/claude-pr-mention.yml
33
+ SETUP.md
34
+ SETUP.sh
35
+ ].freeze
36
+
37
+ TEMPLATE_FILES = (GENERIC_FILES + CLAUDE_FILES).freeze
38
+
39
+ Result = Struct.new(:created, :updated, :unchanged, keyword_init: true)
40
+
41
+ # `ligarb setup-github-review [DIR]` entry point. Sets up the scaffolding in
42
+ # an existing ligarb project (book.yml must exist), enables the reader
43
+ # feedback UI in book.yml, and prints the remaining manual-setup steps.
44
+ # Safe to re-run to pull in updated templates (generated files are
45
+ # overwritten; book.yml is not).
46
+ def self.run(directory = nil)
47
+ target = File.expand_path(directory || ".")
48
+ unless File.exist?(File.join(target, "book.yml"))
49
+ $stderr.puts "Error: book.yml not found in #{target}"
50
+ $stderr.puts "Run 'ligarb init' or 'ligarb write' first, then set up the GitHub review scaffolding."
51
+ exit 1
52
+ end
53
+
54
+ reviewer = new(target)
55
+ # book.yml edits must run BEFORE generate so the templates (SETUP.sh,
56
+ # issue forms, README) are substituted with the resolved repository.
57
+ repository = reviewer.ensure_repository_in_book_yml
58
+ enabled = reviewer.enable_in_book_yml
59
+ readme = reviewer.create_readme_if_absent
60
+ result = reviewer.generate
61
+ reviewer.print_notice(result, repository: repository, enabled: enabled, readme: readme)
62
+ end
63
+
64
+ def initialize(target)
65
+ @target = File.expand_path(target)
66
+ end
67
+
68
+ # Writes all template files into the project, substituting __OWNER__ /
69
+ # __REPO__ from book.yml's `repository:`. Existing files are OVERWRITTEN so a
70
+ # project can follow upstream template changes; only files whose content is
71
+ # already identical are left untouched. Returns a Result listing
72
+ # created/updated/unchanged files.
73
+ def generate
74
+ owner, repo = extract_owner_repo
75
+ created = []
76
+ updated = []
77
+ unchanged = []
78
+
79
+ TEMPLATE_FILES.each do |rel|
80
+ dest = File.join(@target, rel)
81
+ content = render(rel, File.read(File.join(TEMPLATE_DIR, rel)), owner, repo)
82
+
83
+ if !File.exist?(dest)
84
+ write_file(dest, content)
85
+ created << rel
86
+ elsif File.read(dest) == content
87
+ unchanged << rel
88
+ else
89
+ write_file(dest, content)
90
+ updated << rel
91
+ end
92
+ end
93
+
94
+ Result.new(created: created, updated: updated, unchanged: unchanged)
95
+ end
96
+
97
+ # Ensures `github_review.enabled: true` is present in book.yml so the reader
98
+ # feedback UI activates (once `repository` is also set). Appends the key only
99
+ # when absent, preserving existing formatting/comments. Returns :added,
100
+ # :present, or :unsupported (translations hub / unparsable).
101
+ def enable_in_book_yml
102
+ book_yml = File.join(@target, "book.yml")
103
+ data = YAML.safe_load_file(book_yml)
104
+ return :unsupported unless data.is_a?(Hash)
105
+ return :present if data.key?("github_review")
106
+
107
+ content = File.read(book_yml).rstrip
108
+ File.write(book_yml, "#{content}\n\ngithub_review:\n enabled: true\n")
109
+ :added
110
+ end
111
+
112
+ # 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.)
117
+ def ensure_repository_in_book_yml
118
+ book_yml = File.join(@target, "book.yml")
119
+ data = YAML.safe_load_file(book_yml)
120
+ return :unsupported unless data.is_a?(Hash)
121
+ return :present if data.key?("repository")
122
+
123
+ user = ENV["USER"] || ENV["USERNAME"] || "your-github-account"
124
+ @default_repository = "https://github.com/#{user}/#{File.basename(@target)}"
125
+ content = File.read(book_yml).rstrip
126
+ File.write(book_yml, %(#{content}\n\nrepository: "#{@default_repository}"\n))
127
+ :added
128
+ end
129
+
130
+ # Creates a project README.md that links to the published GitHub Pages site,
131
+ # but only when one does not already exist (the reader's own README is never
132
+ # overwritten). Returns :created or :present.
133
+ def create_readme_if_absent
134
+ readme = File.join(@target, "README.md")
135
+ return :present if File.exist?(readme)
136
+
137
+ owner, repo = extract_owner_repo
138
+ pages = owner && repo ? "https://#{owner}.github.io/#{repo}/" : "https://__OWNER__.github.io/__REPO__/"
139
+ issues = owner && repo ? "https://github.com/#{owner}/#{repo}/issues/new?template=book-feedback.yml" \
140
+ : "https://github.com/__OWNER__/__REPO__/issues/new?template=book-feedback.yml"
141
+ title = book_title.to_s.empty? ? "Book" : book_title
142
+
143
+ File.write(readme, <<~MD)
144
+ # #{title}
145
+
146
+ 📖 **公開版(GitHub Pages)**: #{pages}
147
+
148
+ この本は [ligarb](https://github.com/ko1/ligarb) で生成しています。
149
+
150
+ ## フィードバック
151
+
152
+ 本文の誤り・わかりにくい点・疑問は [Issue](#{issues}) からどうぞ。
153
+ 公開ページでは本文を選択して「Report as issue」からも送れます。
154
+
155
+ ## ローカルでビルド
156
+
157
+ ```bash
158
+ ligarb build # build/index.html を生成
159
+ ligarb serve # ローカルプレビュー
160
+ ```
161
+
162
+ セットアップ手順は [SETUP.md](SETUP.md) を参照してください。
163
+ MD
164
+ :created
165
+ end
166
+
167
+ def print_notice(result, repository:, enabled:, readme:)
168
+ puts "Set up GitHub review scaffolding in #{@target}:"
169
+ result.created.each { |path| puts " created #{path}" }
170
+ result.updated.each { |path| puts " updated #{path}" }
171
+ puts " created README.md (with the GitHub Pages link)" if readme == :created
172
+ case repository
173
+ when :added then puts " updated book.yml (repository: #{@default_repository})"
174
+ end
175
+ case enabled
176
+ when :added then puts " updated book.yml (github_review.enabled: true)"
177
+ when :present then puts " kept book.yml github_review setting"
178
+ end
179
+ unless result.unchanged.empty?
180
+ puts " unchanged #{result.unchanged.size} file(s) already up to date"
181
+ end
182
+ if result.updated.any?
183
+ puts
184
+ puts "Note: existing scaffolding files were overwritten with the latest"
185
+ puts "templates. Review with 'git diff' and revert any local edits you"
186
+ puts "want to keep."
187
+ end
188
+ puts
189
+ puts "Next: edit 'repository:' in book.yml if the guess is wrong, then run"
190
+ puts "the gh CLI quickstart:"
191
+ puts " bash SETUP.sh # repo create + secret + Pages + permissions + labels"
192
+ puts
193
+ puts "It still needs a token (see SETUP.md): generate one with"
194
+ puts "'claude setup-token' before running SETUP.sh (it prompts for it)."
195
+ end
196
+
197
+ private
198
+
199
+ def write_file(dest, content)
200
+ FileUtils.mkdir_p(File.dirname(dest))
201
+ File.write(dest, content)
202
+ File.chmod(0o755, dest) if dest.end_with?(".sh")
203
+ end
204
+
205
+ def book_title
206
+ data = YAML.safe_load_file(File.join(@target, "book.yml"))
207
+ data.is_a?(Hash) ? data["title"] : nil
208
+ rescue StandardError
209
+ nil
210
+ end
211
+
212
+ # Substitute __OWNER__ / __REPO__ placeholders. When repository is unset we
213
+ # leave the placeholders as-is, except config.yml's Discussions link which
214
+ # we comment out so the issue chooser does not show a broken URL.
215
+ def render(rel, content, owner, repo)
216
+ if owner && repo
217
+ content.gsub("__OWNER__", owner).gsub("__REPO__", repo)
218
+ elsif rel == ".github/ISSUE_TEMPLATE/config.yml"
219
+ comment_out_contact_links(content)
220
+ else
221
+ content
222
+ end
223
+ end
224
+
225
+ def comment_out_contact_links(content)
226
+ content.lines.map { |line|
227
+ line.start_with?("contact_links:") || line.start_with?(" ") ? "# #{line}" : line
228
+ }.join
229
+ end
230
+
231
+ # Reads book.yml's `repository:` and extracts [owner, repo].
232
+ # Returns [nil, nil] when book.yml or repository is missing/unparsable.
233
+ def extract_owner_repo
234
+ book_yml = File.join(@target, "book.yml")
235
+ return [nil, nil] unless File.exist?(book_yml)
236
+
237
+ data = YAML.safe_load_file(book_yml)
238
+ url = data.is_a?(Hash) ? data["repository"] : nil
239
+ return [nil, nil] unless url.is_a?(String)
240
+
241
+ m = url.chomp("/").match(%r{github\.com[/:]([^/]+)/([^/]+?)(?:\.git)?\z})
242
+ m ? [m[1], m[2]] : [nil, nil]
243
+ end
244
+ end
245
+ end
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "json"
4
+ require "time"
4
5
  require "securerandom"
5
6
  require "fileutils"
6
7
 
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "erb"
4
+ require "json"
4
5
 
5
6
  module Ligarb
6
7
  class Template
@@ -12,7 +13,7 @@ module Ligarb
12
13
  @css_path = File.join(ASSETS_DIR, "style.css")
13
14
  end
14
15
 
15
- def render(config:, chapters:, structure:, assets:, index_entries: [], bibliography: [])
16
+ def render(config:, chapters:, structure:, assets:, index_entries: [], bibliography: [], github_review: nil)
16
17
  css = File.read(@css_path)
17
18
  template = File.read(@template_path)
18
19
 
@@ -35,12 +36,97 @@ module Ligarb
35
36
  b.local_variable_set(:footer, config.effective_footer)
36
37
  b.local_variable_set(:index_tree, build_index_tree(index_entries, chapters))
37
38
  b.local_variable_set(:bibliography, bibliography)
39
+ b.local_variable_set(:multilang, false)
40
+ b.local_variable_set(:langs, [])
41
+ b.local_variable_set(:feedback, load_feedback(github_review))
42
+
43
+ ERB.new(template, trim_mode: "-").result(b)
44
+ end
45
+
46
+ # Render a single HTML with all languages, switchable via JS
47
+ def render_multilang(langs:, assets:, hub_data:, github_review: nil)
48
+ css = File.read(@css_path)
49
+ template = File.read(@template_path)
50
+
51
+ first = langs.first
52
+ first_config = first[:config]
53
+
54
+ custom_css = if first_config.style_path && File.exist?(first_config.style_path)
55
+ File.read(first_config.style_path)
56
+ end
57
+
58
+ # Build per-language template data
59
+ lang_data = langs.map do |ld|
60
+ cfg = ld[:config]
61
+ {
62
+ lang: ld[:lang],
63
+ title: cfg.title,
64
+ author: cfg.author,
65
+ language: cfg.language,
66
+ chapters: ld[:chapters],
67
+ structure: ld[:structure],
68
+ repository: cfg.repository,
69
+ appendix_label: cfg.appendix_label,
70
+ ai_generated: cfg.ai_generated,
71
+ footer: cfg.effective_footer,
72
+ index_tree: build_index_tree(ld[:index_entries], ld[:chapters]),
73
+ bibliography: ld[:bibliography],
74
+ }
75
+ end
76
+
77
+ b = binding
78
+ # Use first language's values as defaults for shared template vars
79
+ b.local_variable_set(:title, first_config.title)
80
+ b.local_variable_set(:author, first_config.author)
81
+ b.local_variable_set(:language, first_config.language)
82
+ b.local_variable_set(:chapters, first[:chapters])
83
+ b.local_variable_set(:structure, first[:structure])
84
+ b.local_variable_set(:css, css)
85
+ b.local_variable_set(:custom_css, custom_css)
86
+ b.local_variable_set(:assets, assets)
87
+ b.local_variable_set(:repository, first_config.repository)
88
+ b.local_variable_set(:appendix_label, first_config.appendix_label)
89
+ b.local_variable_set(:ai_generated, first_config.ai_generated)
90
+ b.local_variable_set(:footer, first_config.effective_footer)
91
+ b.local_variable_set(:index_tree, build_index_tree(first[:index_entries], first[:chapters]))
92
+ b.local_variable_set(:bibliography, first[:bibliography])
93
+ b.local_variable_set(:multilang, true)
94
+ b.local_variable_set(:langs, lang_data)
95
+ b.local_variable_set(:feedback, load_feedback(github_review))
38
96
 
39
97
  ERB.new(template, trim_mode: "-").result(b)
40
98
  end
41
99
 
42
100
  private
43
101
 
102
+ # When github_review (a hash with :base/:issue_template/:labels) is given,
103
+ # returns the inlined feedback assets + a JSON config blob for the template
104
+ # to embed; otherwise nil (the UI is not injected). Inlining keeps the build
105
+ # output self-contained, like the main style.css.
106
+ def load_feedback(github_review)
107
+ return nil unless github_review
108
+
109
+ config_json = JSON.generate(
110
+ base: github_review[:base],
111
+ issueTemplate: github_review[:issue_template],
112
+ labels: github_review[:labels]
113
+ ).gsub("</", '<\/')
114
+
115
+ {
116
+ css: File.read(File.join(ASSETS_DIR, "feedback.css")),
117
+ js: File.read(File.join(ASSETS_DIR, "feedback.js")),
118
+ config_json: config_json,
119
+ }
120
+ end
121
+
122
+ # data-src-* attributes for a chapter <section>, used by the feedback UI to
123
+ # locate the source Markdown. Empty unless the UI is active and the chapter
124
+ # has a resolved source path (set when `repository` is configured).
125
+ def src_attrs(chapter, feedback)
126
+ return "" unless feedback && chapter.relative_path
127
+ %( data-src-file="#{h(chapter.relative_path)}" data-src-title="#{h(chapter.display_title)}")
128
+ end
129
+
44
130
  # HTML-escape helper for ERB templates (available via binding)
45
131
  def h(s)
46
132
  ERB::Util.html_escape(s.to_s)
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Ligarb
4
- VERSION = "0.6.0"
4
+ VERSION = "0.8.0"
5
5
  end
data/lib/ligarb/writer.rb CHANGED
@@ -185,8 +185,8 @@ module Ligarb
185
185
  lines << "Create book.yml first, then each chapter .md file."
186
186
  lines << "Include a cover page for books with 4+ chapters."
187
187
  lines << "Each chapter: substantive content with multiple ## sections."
188
- lines << "Use code blocks, admonitions, mermaid diagrams where appropriate."
189
- lines << "Chapter filenames: 01-topic.md, 02-topic.md, etc."
188
+ lines << "Use code blocks, admonitions, and other ligarb features from the spec where appropriate."
189
+ lines << "Chapter filenames: topic-name.md (e.g. introduction.md, getting-started.md). Do NOT use number prefixes."
190
190
  lines << ""
191
191
  lines << "Create references.bib with real bibliography entries in BibTeX format."
192
192
  lines << "In book.yml, set: bibliography: references.bib"