rakit 0.1.8 → 0.1.9

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.
@@ -1,90 +1,241 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "kramdown"
3
+ require "pathname"
4
4
  require "fileutils"
5
+ require "generated/rakit.markdown_pb"
6
+ require "kramdown"
5
7
 
6
8
  module Rakit
9
+ # Modular document amalgamate, disaggregate, validate (009); merge (legacy); generate_pdf.
10
+ # Document and Section are defined in proto/rakit.markdown.proto (generated in lib/generated/rakit.markdown_pb.rb).
11
+ # See specs/009-markdown-modular-docs/contracts/ruby-api.md
7
12
  module Markdown
8
- class << self
9
- # @param source_path [String] path to markdown file
10
- # @param output_path [String, nil] path for PDF; default same dir, .pdf extension
11
- # @return [Hash] { success: Boolean, message: String, exit_code: Integer, stderr: String (optional) }
12
- def generate_pdf(source_path, output_path = nil)
13
- validated = validate_file_path(source_path)
14
- unless validated
15
- return { success: false, message: "Source path must exist and be a file", exit_code: 1, stderr: "" }
16
- end
17
- # Explicit output_path is relative to CWD (or absolute); default is same dir as source, .pdf extension
18
- out_path = if output_path.to_s.strip.empty?
19
- nil
20
- else
21
- ::File.expand_path(output_path.to_s.strip)
22
- end
23
- out_path ||= ::File.join(::File.dirname(validated), ::File.basename(validated, ".*") + ".pdf")
24
- out_path = ::File.expand_path(out_path)
13
+ # Derive slug from section title: lowercase, non-alphanumerics → '-', collapse '-', trim.
14
+ def self.slug_from_title(title)
15
+ return "" if title.nil? || title.to_s.strip.empty?
16
+ s = title.to_s.downcase
17
+ s = s.gsub(/[^a-z0-9]+/, "-")
18
+ s = s.gsub(/-+/, "-")
19
+ s = s.sub(/\A-+/, "").sub(/-+\z/, "")
20
+ s
21
+ end
22
+
23
+ # Discover .md section files under root_dir, sort by path. Empty → raise.
24
+ def self.discover_section_paths(root_dir)
25
+ root = ::File.expand_path(root_dir)
26
+ raise ArgumentError, "root_dir does not exist: #{root_dir}" unless ::Dir.exist?(root)
27
+ paths = ::Dir.glob(::File.join(root, "**", "*.md")).sort
28
+ paths.reject! { |p| ::File.basename(p).start_with?(".") }
29
+ raise ArgumentError, "no section files under root_dir (at least one .md required): #{root_dir}" if paths.empty?
30
+ paths
31
+ end
32
+
33
+ def self.load_document(root_dir:)
34
+ path = root_dir.to_s
35
+ raise ArgumentError, "root_dir does not exist: #{path}" unless ::File.exist?(path)
36
+ raise ArgumentError, "root_dir is not a directory: #{path}" unless ::File.directory?(path)
37
+ full_paths = discover_section_paths(path)
38
+ root = ::File.expand_path(path)
39
+ title = ::File.basename(root)
40
+ sections = full_paths.map do |fp|
41
+ rel = Pathname.new(fp).relative_path_from(Pathname.new(root)).to_s
42
+ body = ::File.read(fp, encoding: "UTF-8")
43
+ base = ::File.basename(fp, ".md")
44
+ id = slug_from_title(base.sub(/\A\d+-/, "").tr("_", "-"))
45
+ id = "section" if id.empty?
46
+ Section.new(id: id, title: base, body: body, source_path: rel, sections: [])
47
+ end
48
+ Document.new(title: title, root_dir: path, sections: sections)
49
+ end
25
50
 
26
- unless wkhtmltopdf_available?
27
- msg = wkhtmltopdf_install_message
28
- return { success: false, message: msg, exit_code: 1, stderr: msg }
51
+ def self.amalgamate(document:, output_path:)
52
+ raise ArgumentError, "document is required" if document.nil?
53
+ out = output_path.to_s
54
+ buf = +""
55
+ buf << "# #{document.title}\n\n"
56
+ document.sections.each do |sec|
57
+ buf << "## #{sec.title}\n\n"
58
+ buf << sec.body.strip
59
+ buf << "\n\n" unless sec.body.strip.empty?
60
+ end
61
+ ::File.write(out, buf, encoding: "UTF-8")
62
+ true
63
+ rescue => e
64
+ raise e
65
+ end
66
+
67
+ def self.disaggregate(markdown_path:, output_root_dir:)
68
+ path = markdown_path.to_s
69
+ raise ArgumentError, "markdown_path does not exist: #{path}" unless ::File.exist?(path)
70
+ raise ArgumentError, "markdown_path is not a file: #{path}" unless ::File.file?(path)
71
+ content = ::File.read(path, encoding: "UTF-8")
72
+ sections = parse_headings_to_sections(content)
73
+ out_root = ::File.expand_path(output_root_dir.to_s)
74
+ parent = ::File.dirname(out_root)
75
+ tmp_dir = parent + "/.rakit_disaggregate_#{Process.pid}_#{rand(1_000_000)}"
76
+ ::FileUtils.mkdir_p(tmp_dir)
77
+ begin
78
+ sections.each_with_index do |sec, i|
79
+ slug = sec[:slug].to_s.empty? ? "section" : sec[:slug]
80
+ fn = "#{slug}.md"
81
+ fn = "#{i}-#{fn}" if sections.size > 1 && slug == "section"
82
+ out_path = ::File.join(tmp_dir, fn)
83
+ ::File.write(out_path, sec[:body].to_s.strip + "\n", encoding: "UTF-8")
29
84
  end
85
+ ::FileUtils.rm_rf(out_root) if ::Dir.exist?(out_root)
86
+ ::FileUtils.mv(tmp_dir, out_root)
87
+ rescue => e
88
+ ::FileUtils.rm_rf(tmp_dir) if ::Dir.exist?(tmp_dir)
89
+ raise e
90
+ end
91
+ true
92
+ end
30
93
 
31
- temp_html = nil
32
- begin
33
- md_content = ::File.read(validated, encoding: "UTF-8")
34
- html = markdown_to_html(md_content)
35
- temp_html = ::File.join(::File.dirname(out_path), ".rakit_md_#{Process.pid}_#{object_id}.html")
36
- ::File.write(temp_html, html, encoding: "UTF-8")
37
- ok = system("wkhtmltopdf", "-q", temp_html, out_path, out: ::File::NULL, err: ::File::NULL)
38
- unless ok
39
- ::File.unlink(out_path) if ::File.file?(out_path)
40
- return { success: false, message: "wkhtmltopdf failed", exit_code: 1, stderr: "" }
94
+ # Parse markdown by headings ^#+\s+; return array of { level, title, slug, body }.
95
+ def self.parse_headings_to_sections(content)
96
+ lines = content.to_s.lines
97
+ heading_re = /\A(#+)\s+([^\n]*)\s*\z/
98
+ sections = []
99
+ current = { level: 0, title: nil, slug: nil, body: +"" }
100
+ found_any = false
101
+ lines.each do |line|
102
+ m = line.match(heading_re)
103
+ if m
104
+ found_any = true
105
+ level = m[1].length
106
+ title = m[2].strip
107
+ if current[:title]
108
+ current[:body] = current[:body].sub(/\n+\z/, "")
109
+ sections << { level: current[:level], title: current[:title], slug: slug_from_title(current[:title]), body: current[:body] }
41
110
  end
42
- { success: true, message: "", exit_code: 0, stderr: "" }
43
- rescue => e
44
- ::File.unlink(out_path) if out_path && ::File.file?(out_path)
45
- { success: false, message: e.message, exit_code: 1, stderr: "" }
46
- ensure
47
- ::File.unlink(temp_html) if temp_html && ::File.file?(temp_html)
111
+ current = { level: level, title: title, slug: slug_from_title(title), body: +"" }
112
+ else
113
+ current[:body] << line
48
114
  end
49
115
  end
116
+ current[:body] = current[:body].sub(/\n+\z/, "")
117
+ if current[:title] || !found_any
118
+ sections << { level: current[:level], title: current[:title] || "Section", slug: (current[:title] ? slug_from_title(current[:title]) : "section"), body: current[:body] }
119
+ end
120
+ sections = [{ level: 0, title: "Section", slug: "section", body: content.to_s.strip }] if sections.empty?
121
+ sections
122
+ end
50
123
 
51
- # @param source_dir [String] directory to collect .md files from
52
- # @param target_path [String] path to output markdown file
53
- # @param create_directories [Boolean]
54
- # @return [Hash] { success: Boolean, message: String, exit_code: Integer }
55
- def amalgamate(source_dir, target_path, create_directories: false)
56
- validated_dir = validate_dir_path(source_dir)
57
- unless validated_dir
58
- return { success: false, message: "Source directory must exist and be a directory", exit_code: 1 }
59
- end
60
- target_norm = normalize_path(target_path)
61
- unless target_norm
62
- return { success: false, message: "Target path is required", exit_code: 1 }
63
- end
64
- unless target_outside_source?(validated_dir, target_norm)
65
- return { success: false, message: "Target path must be outside source directory", exit_code: 1 }
124
+ class << self
125
+ attr_accessor :validation_failures
126
+ end
127
+ self.validation_failures = []
128
+
129
+ def self.validate(root_dir:)
130
+ self.validation_failures = []
131
+ doc = load_document(root_dir: root_dir)
132
+ ids = []
133
+ doc.sections.each do |sec|
134
+ ids << sec.id
135
+ end
136
+ seen = {}
137
+ ids.each do |id|
138
+ if seen[id]
139
+ self.validation_failures << "Duplicate section id: #{id}"
140
+ else
141
+ seen[id] = true
66
142
  end
67
- parent = ::File.dirname(target_norm)
68
- unless ::File.directory?(parent)
69
- if create_directories
70
- ::FileUtils.mkdir_p(parent)
71
- else
72
- return { success: false, message: "Parent directory does not exist; use create_directories: true", exit_code: 1 }
73
- end
143
+ end
144
+ return false if validation_failures.any?
145
+ true
146
+ rescue ArgumentError => e
147
+ self.validation_failures = [e.message]
148
+ false
149
+ end
150
+
151
+ # ---- Legacy API: merge (concatenate .md files) and generate_pdf ----
152
+
153
+ # Merge: collect all .md under source_dir (sorted by path), write concatenated content to target_path.
154
+ # Target must be outside source_dir. Returns { success:, message:, exit_code: }.
155
+ # @param source_dir [String] directory to collect .md files from
156
+ # @param target_path [String] path to output markdown file
157
+ # @param create_directories [Boolean] if true, create parent dirs of target_path
158
+ def self.merge(source_dir, target_path, create_directories: false)
159
+ validated_dir = validate_dir_path(source_dir)
160
+ unless validated_dir
161
+ return { success: false, message: "Source directory must exist and be a directory", exit_code: 1 }
162
+ end
163
+ target_norm = normalize_path(target_path)
164
+ unless target_norm
165
+ return { success: false, message: "Target path is required", exit_code: 1 }
166
+ end
167
+ unless target_outside_source?(validated_dir, target_norm)
168
+ return { success: false, message: "Target path must be outside source directory", exit_code: 1 }
169
+ end
170
+ parent = ::File.dirname(target_norm)
171
+ unless ::File.directory?(parent)
172
+ if create_directories
173
+ ::FileUtils.mkdir_p(parent)
174
+ else
175
+ return { success: false, message: "Parent directory does not exist; use create_directories: true", exit_code: 1 }
74
176
  end
75
- begin
76
- content = amalgamate_collect(validated_dir)
77
- ::File.write(target_norm, content, encoding: "UTF-8")
78
- { success: true, message: "", exit_code: 0 }
79
- rescue => e
80
- ::File.unlink(target_norm) if ::File.file?(target_norm)
81
- { success: false, message: e.message, exit_code: 1 }
177
+ end
178
+ begin
179
+ content = merge_collect(validated_dir)
180
+ ::File.write(target_norm, content, encoding: "UTF-8")
181
+ { success: true, message: "", exit_code: 0 }
182
+ rescue => e
183
+ ::File.unlink(target_norm) if ::File.file?(target_norm)
184
+ { success: false, message: e.message, exit_code: 1 }
185
+ end
186
+ end
187
+
188
+ # Generate PDF from a markdown file (requires weasyprint or wkhtmltopdf on PATH; on Mac/Linux weasyprint is preferred).
189
+ # @param source_path [String] path to markdown file
190
+ # @param output_path [String, nil] path for PDF; default same dir, .pdf extension
191
+ # @return [Hash] { success:, message:, exit_code:, stderr: }
192
+ def self.generate_pdf(source_path, output_path = nil)
193
+ validated = validate_file_path(source_path)
194
+ unless validated
195
+ return { success: false, message: "Source path must exist and be a file", exit_code: 1, stderr: "" }
196
+ end
197
+ out_path = if output_path.to_s.strip.empty?
198
+ nil
199
+ else
200
+ ::File.expand_path(output_path.to_s.strip)
201
+ end
202
+ out_path ||= ::File.join(::File.dirname(validated), ::File.basename(validated, ".*") + ".pdf")
203
+ out_path = ::File.expand_path(out_path)
204
+
205
+ engine = pdf_engine_available?
206
+ unless engine
207
+ msg = pdf_engine_install_message
208
+ return { success: false, message: msg, exit_code: 1, stderr: msg }
209
+ end
210
+
211
+ temp_html = nil
212
+ begin
213
+ md_content = ::File.read(validated, encoding: "UTF-8")
214
+ html = wrap_html_for_pdf(markdown_to_html(md_content))
215
+ temp_html = ::File.join(::File.dirname(out_path), ".rakit_md_#{Process.pid}_#{object_id}.html")
216
+ ::File.write(temp_html, html, encoding: "UTF-8")
217
+ ok = run_pdf_engine(engine, temp_html, out_path)
218
+ unless ok
219
+ ::File.unlink(out_path) if ::File.file?(out_path)
220
+ return { success: false, message: "#{engine} failed", exit_code: 1, stderr: "" }
82
221
  end
222
+ { success: true, message: "", exit_code: 0, stderr: "" }
223
+ rescue => e
224
+ ::File.unlink(out_path) if out_path && ::File.file?(out_path)
225
+ { success: false, message: e.message, exit_code: 1, stderr: "" }
226
+ ensure
227
+ ::File.unlink(temp_html) if temp_html && ::File.file?(temp_html)
83
228
  end
229
+ end
230
+
231
+ # True if a PDF engine (weasyprint or wkhtmltopdf) is available for generate_pdf.
232
+ def self.pdf_available?
233
+ send(:pdf_engine_available?).nil? ? false : true
234
+ end
84
235
 
236
+ class << self
85
237
  private
86
238
 
87
- # T004: Normalize path; return nil if nil/empty after strip.
88
239
  def normalize_path(path, base_dir = nil)
89
240
  return nil if path.nil?
90
241
  s = path.to_s.strip
@@ -92,7 +243,6 @@ module Rakit
92
243
  base_dir ? ::File.expand_path(s, base_dir) : ::File.expand_path(s)
93
244
  end
94
245
 
95
- # T004: Validate path exists and is a file. Return expanded path or nil.
96
246
  def validate_file_path(path)
97
247
  norm = normalize_path(path)
98
248
  return nil unless norm
@@ -100,7 +250,6 @@ module Rakit
100
250
  norm
101
251
  end
102
252
 
103
- # T004: Validate path exists and is a directory. Return expanded path or nil.
104
253
  def validate_dir_path(path)
105
254
  norm = normalize_path(path)
106
255
  return nil unless norm
@@ -108,7 +257,6 @@ module Rakit
108
257
  norm
109
258
  end
110
259
 
111
- # T005: Target is outside source_dir (not equal, not under). Both should be normalized.
112
260
  def target_outside_source?(source_dir, target_path)
113
261
  src = source_dir.to_s.gsub(::File::SEPARATOR, "/").chomp("/")
114
262
  target = ::File.expand_path(target_path).to_s.gsub(::File::SEPARATOR, "/")
@@ -119,38 +267,82 @@ module Rakit
119
267
  true
120
268
  end
121
269
 
122
- # T006: True if wkhtmltopdf is available.
270
+ def mac_or_linux?
271
+ RUBY_PLATFORM.include?("darwin") || RUBY_PLATFORM.include?("linux")
272
+ end
273
+
123
274
  def wkhtmltopdf_available?
124
- out = `wkhtmltopdf --version 2>&1`
275
+ `wkhtmltopdf --version 2>&1`
276
+ $?.success?
277
+ end
278
+
279
+ def weasyprint_available?
280
+ `weasyprint --version 2>&1`
125
281
  $?.success?
126
282
  end
127
283
 
128
- # T006: Platform-specific install message for wkhtmltopdf.
129
- def wkhtmltopdf_install_message
284
+ # Returns :weasyprint or :wkhtmltopdf if available. On Mac/Linux prefers weasyprint.
285
+ def pdf_engine_available?
286
+ if mac_or_linux?
287
+ return :weasyprint if weasyprint_available?
288
+ return :wkhtmltopdf if wkhtmltopdf_available?
289
+ else
290
+ return :wkhtmltopdf if wkhtmltopdf_available?
291
+ return :weasyprint if weasyprint_available?
292
+ end
293
+ nil
294
+ end
295
+
296
+ def pdf_engine_install_message
130
297
  if ::Gem.win_platform?
131
298
  "Install wkhtmltopdf: choco install wkhtmltopdf"
132
- elsif ::File.exist?("/usr/bin/sw_vers") || RUBY_PLATFORM.include?("darwin")
133
- "Install wkhtmltopdf: brew install wkhtmltopdf"
299
+ elsif mac_or_linux?
300
+ "Install weasyprint: brew install weasyprint (macOS) or apt install weasyprint (Debian/Ubuntu). Alternatively: wkhtmltopdf."
301
+ else
302
+ "Install wkhtmltopdf or weasyprint for PDF generation."
303
+ end
304
+ end
305
+
306
+ def run_pdf_engine(engine, html_path, pdf_path)
307
+ case engine
308
+ when :weasyprint
309
+ system("weasyprint", "-e", "UTF-8", html_path, pdf_path, out: ::File::NULL, err: ::File::NULL)
310
+ when :wkhtmltopdf
311
+ system("wkhtmltopdf", "-q", "--encoding", "UTF-8", html_path, pdf_path, out: ::File::NULL, err: ::File::NULL)
134
312
  else
135
- "Install wkhtmltopdf: e.g. apt install wkhtmltopdf or yum install wkhtmltopdf"
313
+ false
136
314
  end
137
315
  end
138
316
 
139
- # T007: Convert markdown string to HTML. Empty input -> minimal HTML.
140
317
  def markdown_to_html(md_string)
141
318
  return "" if md_string.nil? || md_string.to_s.strip.empty?
142
319
  Kramdown::Document.new(md_string.to_s).to_html
143
320
  end
144
321
 
145
- # T008: Collect all **/*.md under source_dir, sort by path, concatenate with separator. Return string.
146
- AMALGAM_SEPARATOR = "\n\n"
322
+ def wrap_html_for_pdf(html_fragment)
323
+ <<~HTML
324
+ <!DOCTYPE html>
325
+ <html>
326
+ <head>
327
+ <meta charset="UTF-8">
328
+ <meta http-equiv="Content-Type" content="text/html; charset=utf-8">
329
+ <style>body { font-size: 200%; }</style>
330
+ </head>
331
+ <body>
332
+ #{html_fragment}
333
+ </body>
334
+ </html>
335
+ HTML
336
+ end
337
+
338
+ MERGE_SEPARATOR = "\n\n"
147
339
 
148
- def amalgamate_collect(source_dir)
340
+ def merge_collect(source_dir)
149
341
  norm_dir = ::File.expand_path(source_dir)
150
342
  pattern = ::File.join(norm_dir, "**", "*.md")
151
343
  paths = ::Dir.glob(pattern).select { |p| ::File.file?(p) }.sort
152
344
  return "" if paths.empty?
153
- paths.map { |p| ::File.read(p, encoding: "UTF-8") }.join(AMALGAM_SEPARATOR)
345
+ paths.map { |p| ::File.read(p, encoding: "UTF-8") }.join(MERGE_SEPARATOR)
154
346
  end
155
347
  end
156
348
  end
data/lib/rakit/shell.rb CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  require "open3"
4
4
  require "timeout"
5
- require "generated/shell_pb"
5
+ require "generated/rakit.shell_pb"
6
6
 
7
7
  module Rakit
8
8
  module Shell
@@ -1,28 +1,42 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "fileutils"
4
- require "generated/static_web_server_pb"
4
+ require "generated/rakit.static_web_server_pb"
5
5
 
6
6
  module Rakit
7
- # Publish static sites into a configurable root, regenerate a root index, and control a local HTTP server (start/stop/running).
8
- # CLI: +rakit static-web-server+ (start|stop|running|publish). See contracts/ruby-api.md and quickstart in specs/003-static-web-server/quickstart.md.
7
+ # Publish static sites into a configurable root (~/.rakit/wwwroot), regenerate a root index, and control a local HTTP server (start/stop/running).
8
+ # Host and port are configurable (host default 127.0.0.1, port 5099) and returned regardless of server state for URL building.
9
+ # CLI: +rakit static-web-server+ (start|stop|running|publish|view). See specs/003-static-web-server/contracts/ruby-api.md and specs/007-wwwroot-docs-hugo-view/contracts/ruby-api.md.
9
10
  module StaticWebServer
10
11
  SITE_NAME_REGEX = /\A[a-z0-9\-]+\z/.freeze
11
12
 
12
13
  class << self
13
- # @return [String] Static root directory (default ~/.rakit/www_root). Used by publish and server lifecycle.
14
+ # @return [String] Static root directory (default ~/.rakit/wwwroot). Used by publish and server lifecycle.
14
15
  attr_accessor :root
15
- # @return [Integer] Default port for start (default 5099). Used when no override passed to start.
16
+ # @return [String] Host for building URLs (default 127.0.0.1). Returned regardless of server running state.
17
+ attr_accessor :host
18
+ # @return [Integer] Default port for start (default 5099). Used when no override passed to start; returned regardless of server running state.
16
19
  attr_accessor :port
17
20
  end
18
21
 
19
- self.root = ::File.expand_path("~/.rakit/www_root")
22
+ self.root = ::File.expand_path("~/.rakit/wwwroot")
23
+ self.host = "127.0.0.1"
20
24
  self.port = 5099
21
25
 
22
26
  # T005: Validate site name; raise ArgumentError before any filesystem write.
27
+ # Single segment (e.g. "mysite") or multi-segment path (e.g. "louparslow/rakit"); each segment must match SITE_NAME_REGEX.
23
28
  def self.validate_site_name!(site_name)
24
- return if site_name.is_a?(String) && site_name.match?(SITE_NAME_REGEX)
25
- raise ArgumentError, "site_name must be lowercase alphanumeric and dashes only (e.g. my-site); got: #{site_name.inspect}"
29
+ validate_site_path!(site_name)
30
+ end
31
+
32
+ def self.validate_site_path!(site_name)
33
+ return if site_name.nil? || site_name.to_s.empty?
34
+ site_name.to_s.split(::File::SEPARATOR).each do |seg|
35
+ next if seg.empty?
36
+ unless seg.match?(SITE_NAME_REGEX)
37
+ raise ArgumentError, "site_name segment must be lowercase alphanumeric and dashes only (e.g. my-site); got: #{seg.inspect}"
38
+ end
39
+ end
26
40
  end
27
41
 
28
42
  # T006: PID file path for start/stop/running (research: ~/.rakit/static_web_server.pid).
@@ -49,26 +63,27 @@ module Rakit
49
63
  end
50
64
 
51
65
  # Publish static content from source_directory to root/site_name (atomic copy), then regenerate root index.
52
- # @param site_name [String] Must match \\A[a-z0-9\-]+\\z (lowercase, alphanumeric, dashes only).
66
+ # @param site_name [String] Single segment (e.g. "mysite") or path (e.g. "louparslow/rakit"); each segment must match \\A[a-z0-9\-]+\\z.
53
67
  # @param source_directory [String] Existing directory path; contents are copied (no traversal outside allowed paths).
54
68
  # @return [true] on success.
55
69
  # @raise [ArgumentError] for invalid site_name, missing/invalid source_directory, or root not writable.
56
70
  def self.publish(site_name, source_directory)
57
- validate_site_name!(site_name)
71
+ validate_site_path!(site_name)
58
72
  src = ::File.expand_path(source_directory)
59
73
  raise ArgumentError, "source_directory does not exist: #{source_directory}" unless ::File.exist?(src)
60
74
  raise ArgumentError, "source_directory is not a directory: #{source_directory}" unless ::File.directory?(src)
61
75
 
62
76
  ensure_root
63
- # Check root is writable before we do any copy (T015 / edge case).
64
77
  unless ::File.writable?(root)
65
78
  raise ArgumentError, "root directory is not writable: #{root}"
66
79
  end
67
80
 
68
81
  target = ::File.join(root, site_name)
69
- temp = ::File.join(root, ".tmp_#{site_name}_#{Process.pid}_#{rand(1_000_000)}")
82
+ temp_name = ".tmp_#{site_name.to_s.gsub(::File::SEPARATOR, '_')}_#{Process.pid}_#{rand(1_000_000)}"
83
+ temp = ::File.join(root, temp_name)
70
84
  FileUtils.mkdir_p(temp)
71
85
  FileUtils.cp_r(::File.join(src, "."), temp)
86
+ FileUtils.mkdir_p(::File.dirname(target))
72
87
  FileUtils.rm_rf(target)
73
88
  FileUtils.mv(temp, target)
74
89
  regenerate_root_index
@@ -95,6 +110,7 @@ module Rakit
95
110
  def self.start(options = {})
96
111
  return true if running?
97
112
  ensure_root
113
+ regenerate_root_index # so root / (Sites page) lists current nested sites
98
114
  p = options[:port] || port
99
115
  raise "port #{p} is already in use; change port or stop the other process" if port_in_use?(p)
100
116
  bin = server_binary
@@ -109,19 +125,23 @@ module Rakit
109
125
 
110
126
  STOP_TIMEOUT = 5
111
127
 
112
- # Gracefully terminate server process; remove PID file. Fail-fast if not running.
113
- # @return [true] if stopped.
114
- # @raise [RuntimeError] if server was not running (no PID file or process not found).
128
+ # Gracefully terminate server process; remove PID file. Idempotent: if not running (no PID file or process not found), no-op and return true.
129
+ # @return [true] if stopped or already not running.
115
130
  def self.stop
116
131
  path = pid_file_path
117
- raise "server is not running (no PID file at #{path})" unless ::File.file?(path)
132
+ unless ::File.file?(path)
133
+ return true # already not running
134
+ end
118
135
  pid = ::File.read(path).strip.to_i
119
- raise "server is not running (invalid PID in #{path})" if pid <= 0
136
+ if pid <= 0
137
+ ::File.delete(path) rescue nil
138
+ return true
139
+ end
120
140
  begin
121
141
  Process.getpgid(pid)
122
142
  rescue Errno::ESRCH
123
- ::File.delete(path)
124
- raise "server is not running (process #{pid} not found)"
143
+ ::File.delete(path) rescue nil
144
+ return true # process already gone; treat as stopped
125
145
  end
126
146
  Process.kill(:TERM, pid)
127
147
  deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + STOP_TIMEOUT
@@ -145,17 +165,49 @@ module Rakit
145
165
  true
146
166
  end
147
167
 
148
- # T017: Write root/index.html listing all site subdirectories alphabetically with links.
168
+ # Open a path on the static server in the default browser. Ensures server is running (starts if not), builds URL, launches browser.
169
+ # @param relative_path [String] URL path (e.g. "/louparslow/rakit/" or "louparslow/rakit"); normalized to one leading slash.
170
+ # @return [true] on success.
171
+ # @raise [RuntimeError] if browser cannot be launched (e.g. headless), with message e.g. "Could not launch browser; display required?"
172
+ def self.view(relative_path)
173
+ start unless running?
174
+ path = relative_path.to_s.strip
175
+ path = "/#{path}" unless path.empty? || path.start_with?("/")
176
+ path = "/" if path.empty?
177
+ url = "http://#{host}:#{port}#{path}"
178
+ $stdout.puts "Opening #{url}"
179
+ launch_browser(url)
180
+ true
181
+ end
182
+
183
+ # T017: Write root/index.html listing all subdirectories that have index.html (or index.xml) with links.
149
184
  def self.regenerate_root_index
150
- entries = Dir.entries(root).sort.select do |e|
151
- e != "." && e != ".." && e != "index.html" && !e.start_with?(".") && ::File.directory?(::File.join(root, e))
152
- end
185
+ paths = []
186
+ _collect_index_paths(root, "", paths)
187
+ paths.sort!
153
188
  html = ["<!DOCTYPE html>", "<html><head><meta charset=\"utf-8\"><title>Published sites</title></head><body>", "<h1>Published sites</h1>", "<ul>"]
154
- entries.each { |name| html << "<li><a href=\"/#{name}/\">#{name}</a></li>" }
189
+ paths.each do |rel|
190
+ name = rel.chomp("/").split("/").last || rel
191
+ html << "<li><a href=\"/#{rel}\">#{name} (#{rel})</a></li>"
192
+ end
155
193
  html << "</ul></body></html>"
156
194
  ::File.write(::File.join(root, "index.html"), html.join("\n"))
157
195
  end
158
196
 
197
+ # Recursively find relative paths under dir (relative to root) that contain index.html or index.xml.
198
+ def self._collect_index_paths(dir, rel, out)
199
+ Dir.entries(dir).sort.each do |e|
200
+ next if e == "." || e == ".." || e.start_with?(".")
201
+ full = ::File.join(dir, e)
202
+ next unless ::File.directory?(full)
203
+ sub_rel = rel.empty? ? e : "#{rel}/#{e}"
204
+ index_html = ::File.join(full, "index.html")
205
+ index_xml = ::File.join(full, "index.xml")
206
+ out << "#{sub_rel}/" if ::File.file?(index_html) || ::File.file?(index_xml)
207
+ _collect_index_paths(full, sub_rel, out)
208
+ end
209
+ end
210
+
159
211
  class << self
160
212
  private
161
213
 
@@ -178,6 +230,20 @@ module Rakit
178
230
  rescue Errno::EADDRINUSE
179
231
  true
180
232
  end
233
+
234
+ def launch_browser(url)
235
+ cmd = case ::RbConfig::CONFIG["host_os"]
236
+ when /darwin|mac os/i
237
+ ["open", url]
238
+ when /mswin|mingw|windows/i
239
+ ["cmd", "/c", "start", "", url]
240
+ else
241
+ ["xdg-open", url]
242
+ end
243
+ system(*cmd, out: ::File::NULL, err: ::File::NULL)
244
+ return true if $?.success?
245
+ raise "Could not launch browser; display required?"
246
+ end
181
247
  end
182
248
  end
183
249
  end
data/lib/rakit.rb CHANGED
@@ -25,6 +25,9 @@ require_relative "rakit/protobuf"
25
25
  # Defer loading so rake tasks that don't need Shell (e.g. clobber) work without google-protobuf.
26
26
  autoload :Shell, "rakit/shell"
27
27
  require_relative "rakit/static_web_server"
28
+ require_relative "rakit/docfx"
29
+ require_relative "rakit/hugo"
30
+ require_relative "rakit/markdown"
28
31
  require_relative "rakit/word_count"
29
32
  require_relative "rakit/word_cloud"
30
33
  require_relative "rakit/file"