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.
- checksums.yaml +4 -4
- data/exe/rakit +105 -7
- data/lib/generated/azure.devops_pb.rb +1 -1
- data/lib/generated/example_pb.rb +1 -1
- data/lib/generated/rakit.azure_pb.rb +26 -0
- data/lib/generated/rakit.docfx_pb.rb +18 -0
- data/lib/generated/rakit.example_pb.rb +18 -0
- data/lib/generated/rakit.markdown_pb.rb +19 -0
- data/lib/generated/rakit.shell_pb.rb +22 -0
- data/lib/generated/rakit.static_web_server_pb.rb +20 -0
- data/lib/rakit/azure/dev_ops.rb +1 -1
- data/lib/rakit/docfx.rb +72 -0
- data/lib/rakit/gem.rb +19 -0
- data/lib/rakit/hugo.rb +43 -0
- data/lib/rakit/markdown.rb +274 -82
- data/lib/rakit/shell.rb +1 -1
- data/lib/rakit/static_web_server.rb +90 -24
- data/lib/rakit.rb +3 -0
- metadata +9 -4
- data/lib/rakit/cli/markdown.rb +0 -90
- data/lib/rakit/cli/word_cloud.rb +0 -225
- data/lib/rakit/word_cloud.rb +0 -247
data/lib/rakit/markdown.rb
CHANGED
|
@@ -1,90 +1,241 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require "
|
|
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
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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
|
-
{
|
|
43
|
-
|
|
44
|
-
|
|
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
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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
|
-
|
|
270
|
+
def mac_or_linux?
|
|
271
|
+
RUBY_PLATFORM.include?("darwin") || RUBY_PLATFORM.include?("linux")
|
|
272
|
+
end
|
|
273
|
+
|
|
123
274
|
def wkhtmltopdf_available?
|
|
124
|
-
|
|
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
|
-
#
|
|
129
|
-
def
|
|
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
|
|
133
|
-
"Install
|
|
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
|
-
|
|
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
|
-
|
|
146
|
-
|
|
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
|
|
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(
|
|
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
|
@@ -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
|
-
#
|
|
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/
|
|
14
|
+
# @return [String] Static root directory (default ~/.rakit/wwwroot). Used by publish and server lifecycle.
|
|
14
15
|
attr_accessor :root
|
|
15
|
-
# @return [
|
|
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/
|
|
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
|
-
|
|
25
|
-
|
|
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]
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
132
|
+
unless ::File.file?(path)
|
|
133
|
+
return true # already not running
|
|
134
|
+
end
|
|
118
135
|
pid = ::File.read(path).strip.to_i
|
|
119
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
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
|
-
|
|
151
|
-
|
|
152
|
-
|
|
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
|
-
|
|
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"
|