Module: Rakit::Markdown
- Defined in:
- lib/rakit/markdown.rb,
lib/generated/rakit.markdown_pb.rb
Overview
Modular document amalgamate, disaggregate, validate (009); merge (legacy); generate_pdf. Outline/toc/tree/outline renderers (010): see specs/010-markdown-outline. Document and Section are defined in proto/rakit.markdown.proto (generated in lib/generated/rakit.markdown_pb.rb). See specs/009-markdown-modular-docs/contracts/ruby-api.md
Defined Under Namespace
Classes: Outline, OutlineNode
Constant Summary collapse
- EXIT_SUCCESS =
Exit codes for renderer CLI (toc, tree, outline). 0=success, 2=invalid input, 3=validation, 4=internal.
0- EXIT_INVALID_INPUT =
2- EXIT_VALIDATION =
3- EXIT_INTERNAL =
4- Section =
::Google::Protobuf::DescriptorPool.generated_pool.lookup("rakit.markdown.Section").msgclass
- Document =
::Google::Protobuf::DescriptorPool.generated_pool.lookup("rakit.markdown.Document").msgclass
- ValidationResult =
::Google::Protobuf::DescriptorPool.generated_pool.lookup("rakit.markdown.ValidationResult").msgclass
Class Attribute Summary collapse
-
.validation_failures ⇒ Object
Returns the value of attribute validation_failures.
Class Method Summary collapse
- .amalgamate(document:, output_path:) ⇒ Object
- .compute_href(node, href_mode: :file_anchor, site_base: nil) ⇒ Object
-
.decode_document_json(json) ⇒ Object
Decode Document from JSON (protobuf JSON).
-
.decode_outline_json(json) ⇒ Object
Decode Outline from JSON.
- .decode_outline_node_hash(h) ⇒ Object
- .decode_section_hash(h) ⇒ Object
- .dedupe_sibling_ids!(siblings, dedupe: false) ⇒ Object
- .disaggregate(markdown_path:, output_root_dir:) ⇒ Object
-
.discover_section_paths(root_dir) ⇒ Object
Discover .md section files under root_dir, sort by path.
-
.document_to_outline(doc) ⇒ Object
Build Outline from Document (map Section → OutlineNode recursively).
-
.generate_pdf(source_path, output_path = nil) ⇒ Hash
Generate PDF from a markdown file (requires weasyprint or wkhtmltopdf on PATH; on Mac/Linux weasyprint is preferred).
- .load_document(root_dir:) ⇒ Object
-
.load_outline_from_options(opts, href_mode: :file_anchor, site_base: nil) ⇒ Object
Load and normalize outline from options.
-
.merge(source_dir, target_path, create_directories: false) ⇒ Object
Merge: collect all .md under source_dir (sorted by path), write concatenated content to target_path.
- .normalize_node!(node, href_mode: :file_anchor, site_base: nil, dedupe: false) ⇒ Object
- .normalize_node_href!(node, href_mode: :file_anchor, site_base: nil) ⇒ Object
-
.normalize_outline!(outline, href_mode: :file_anchor, site_base: nil, dedupe: false) ⇒ Object
Apply slug (when id blank), sibling dedupe, and href.
-
.outline_from_root(root_dir) ⇒ Object
Load outline from root dir.
-
.parse_headings_to_sections(content) ⇒ Object
Parse markdown by headings ^#
\s; return array of { level, title, slug, body }. -
.parse_renderer_args(argv) ⇒ Object
Parse argv for renderer commands; returns { root:, document_json:, outline_json:, max_depth:, dedupe:, strict_paths:, … }.
-
.pdf_available? ⇒ Boolean
True if a PDF engine (weasyprint or wkhtmltopdf) is available for generate_pdf.
-
.render_outline(outline, max_depth: nil, show_ids: false, no_numbers: false) ⇒ Object
Render outline as numbered list (1., 1.1., …) or indented text.
- .render_outline_nodes(buf, nodes, depth, max_depth: nil, show_ids: false, no_numbers: false, indent: "", numbers: []) ⇒ Object
-
.render_toc(outline, max_depth: nil, no_links: false, href_mode: :file_anchor, site_base: nil, show_ids: false) ⇒ Object
Render outline as Markdown TOC (nested bullets, optional links).
- .render_toc_nodes(buf, nodes, depth, max_depth: nil, no_links: false, show_ids: false, indent: "") ⇒ Object
-
.render_tree(outline, max_depth: nil, show_ids: false, show_hrefs: false, ascii: false) ⇒ Object
Render outline as tree (box-drawing or ASCII).
- .render_tree_nodes(buf, nodes, depth, max_depth: nil, show_ids: false, show_hrefs: false, ascii: false, prefix: "") ⇒ Object
-
.resolve_renderer_input(opts) ⇒ Object
Resolve which input source from parsed opts (precedence: outline_json > document_json > root).
- .section_to_outline_node(sec) ⇒ Object
-
.slug_for_outline(title) ⇒ Object
Deterministic slug for outline node id: trim, lower-case, NFKD (when available), strip diacritics, non-→-, trim -, empty→“section”.
-
.slug_from_title(title) ⇒ Object
Derive slug from section title: lowercase, non-alphanumerics → ‘-’, collapse ‘-’, trim.
- .validate(root_dir:) ⇒ Object
-
.validate_outline(outline, max_depth: nil, dedupe: false, strict_paths: false) ⇒ Object
Validate outline: empty title, duplicate explicit id among siblings, max_depth≤0.
- .validate_outline_nodes!(nodes, depth, errors, warnings, dedupe: false, strict_paths: false) ⇒ Object
Class Attribute Details
.validation_failures ⇒ Object
Returns the value of attribute validation_failures.
146 147 148 |
# File 'lib/rakit/markdown.rb', line 146 def validation_failures @validation_failures end |
Class Method Details
.amalgamate(document:, output_path:) ⇒ Object
72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 |
# File 'lib/rakit/markdown.rb', line 72 def self.amalgamate(document:, output_path:) raise ArgumentError, "document is required" if document.nil? out = output_path.to_s buf = +"" buf << "# #{document.title}\n\n" document.sections.each do |sec| buf << "## #{sec.title}\n\n" buf << sec.body.strip buf << "\n\n" unless sec.body.strip.empty? end ::File.write(out, buf, encoding: "UTF-8") true rescue => e raise e end |
.compute_href(node, href_mode: :file_anchor, site_base: nil) ⇒ Object
238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 |
# File 'lib/rakit/markdown.rb', line 238 def self.compute_href(node, href_mode: :file_anchor, site_base: nil) id = node.id.to_s case href_mode when :anchor then "##{id}" when :none then "" else path = node.source_path.to_s.strip if site_base.to_s.strip.empty? path.empty? ? "##{id}" : "#{path}##{id}" else base = site_base.to_s.chomp("/") path_no_ext = path.sub(/\.md\z/i, "").tr("\\", "/") path_no_ext.empty? ? "##{id}" : "#{base}/#{path_no_ext}/##{id}" end end end |
.decode_document_json(json) ⇒ Object
Decode Document from JSON (protobuf JSON). Minimal implementation: parse and build Document from hash.
362 363 364 365 366 367 368 369 |
# File 'lib/rakit/markdown.rb', line 362 def self.decode_document_json(json) require "json" h = ::JSON.parse(json) title = h["title"] || h["rootDir"] && ::File.basename(h["rootDir"]) || "" root_dir = h["rootDir"].to_s sections = (h["sections"] || []).map { |s| decode_section_hash(s) } Document.new(title: title, root_dir: root_dir, sections: sections) end |
.decode_outline_json(json) ⇒ Object
Decode Outline from JSON. Format: { “title”: “”, “nodes”: [ { “title”, “id”, “href”, “source_path”, “children”: [] } ] }.
449 450 451 452 453 454 455 |
# File 'lib/rakit/markdown.rb', line 449 def self.decode_outline_json(json) require "json" h = ::JSON.parse(json) title = (h["title"] || "").to_s.strip nodes = (h["nodes"] || []).map { |n| decode_outline_node_hash(n) } Outline.new(title: title, nodes: nodes) end |
.decode_outline_node_hash(h) ⇒ Object
457 458 459 460 461 462 463 464 465 466 |
# File 'lib/rakit/markdown.rb', line 457 def self.decode_outline_node_hash(h) children = (h["children"] || []).map { |c| decode_outline_node_hash(c) } OutlineNode.new( title: (h["title"] || "").to_s, id: (h["id"] || "").to_s.strip.empty? ? nil : (h["id"] || "").to_s, href: (h["href"] || "").to_s.strip.empty? ? nil : (h["href"] || "").to_s, source_path: (h["sourcePath"] || h["source_path"] || "").to_s.strip.empty? ? nil : (h["sourcePath"] || h["source_path"]).to_s, children: children ) end |
.decode_section_hash(h) ⇒ Object
371 372 373 374 375 376 377 |
# File 'lib/rakit/markdown.rb', line 371 def self.decode_section_hash(h) id = (h["id"] || "").to_s title = (h["title"] || "").to_s source_path = (h["sourcePath"] || h["source_path"] || "").to_s sections = (h["sections"] || []).map { |s| decode_section_hash(s) } Section.new(id: id, title: title, body: (h["body"] || "").to_s, source_path: source_path, sections: sections) end |
.dedupe_sibling_ids!(siblings, dedupe: false) ⇒ Object
221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 |
# File 'lib/rakit/markdown.rb', line 221 def self.dedupe_sibling_ids!(siblings, dedupe: false) seen = {} siblings.each do |n| id = n.id.to_s if seen.key?(id) if dedupe idx = 2 idx += 1 while seen.key?("#{id}-#{idx}") n.id = "#{id}-#{idx}" seen[n.id] = true end else seen[id] = true end end end |
.disaggregate(markdown_path:, output_root_dir:) ⇒ Object
88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 |
# File 'lib/rakit/markdown.rb', line 88 def self.disaggregate(markdown_path:, output_root_dir:) path = markdown_path.to_s raise ArgumentError, "markdown_path does not exist: #{path}" unless ::File.exist?(path) raise ArgumentError, "markdown_path is not a file: #{path}" unless ::File.file?(path) content = ::File.read(path, encoding: "UTF-8") sections = parse_headings_to_sections(content) out_root = ::File.(output_root_dir.to_s) parent = ::File.dirname(out_root) tmp_dir = parent + "/.rakit_disaggregate_#{Process.pid}_#{rand(1_000_000)}" ::FileUtils.mkdir_p(tmp_dir) begin sections.each_with_index do |sec, i| slug = sec[:slug].to_s.empty? ? "section" : sec[:slug] fn = "#{slug}.md" fn = "#{i}-#{fn}" if sections.size > 1 && slug == "section" out_path = ::File.join(tmp_dir, fn) ::File.write(out_path, sec[:body].to_s.strip + "\n", encoding: "UTF-8") end ::FileUtils.rm_rf(out_root) if ::Dir.exist?(out_root) ::FileUtils.mv(tmp_dir, out_root) rescue => e ::FileUtils.rm_rf(tmp_dir) if ::Dir.exist?(tmp_dir) raise e end true end |
.discover_section_paths(root_dir) ⇒ Object
Discover .md section files under root_dir, sort by path. Empty → raise.
45 46 47 48 49 50 51 52 |
# File 'lib/rakit/markdown.rb', line 45 def self.discover_section_paths(root_dir) root = ::File.(root_dir) raise ArgumentError, "root_dir does not exist: #{root_dir}" unless ::Dir.exist?(root) paths = ::Dir.glob(::File.join(root, "**", "*.md")).sort paths.reject! { |p| ::File.basename(p).start_with?(".") } raise ArgumentError, "no section files under root_dir (at least one .md required): #{root_dir}" if paths.empty? paths end |
.document_to_outline(doc) ⇒ Object
Build Outline from Document (map Section → OutlineNode recursively). Does not normalize.
184 185 186 187 188 |
# File 'lib/rakit/markdown.rb', line 184 def self.document_to_outline(doc) return Outline.new(title: doc.title, nodes: []) if doc.nil? nodes = (doc.sections || []).map { |sec| section_to_outline_node(sec) } Outline.new(title: doc.title.to_s.strip, nodes: nodes) end |
.generate_pdf(source_path, output_path = nil) ⇒ Hash
Generate PDF from a markdown file (requires weasyprint or wkhtmltopdf on PATH; on Mac/Linux weasyprint is preferred).
509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 |
# File 'lib/rakit/markdown.rb', line 509 def self.generate_pdf(source_path, output_path = nil) validated = validate_file_path(source_path) unless validated return { success: false, message: "Source path must exist and be a file", exit_code: 1, stderr: "" } end out_path = if output_path.to_s.strip.empty? nil else ::File.(output_path.to_s.strip) end out_path ||= ::File.join(::File.dirname(validated), ::File.basename(validated, ".*") + ".pdf") out_path = ::File.(out_path) engine = pdf_engine_available? unless engine msg = return { success: false, message: msg, exit_code: 1, stderr: msg } end temp_html = nil begin md_content = ::File.read(validated, encoding: "UTF-8") html = wrap_html_for_pdf(markdown_to_html(md_content)) temp_html = ::File.join(::File.dirname(out_path), ".rakit_md_#{Process.pid}_#{object_id}.html") ::File.write(temp_html, html, encoding: "UTF-8") ok = run_pdf_engine(engine, temp_html, out_path) unless ok ::File.unlink(out_path) if ::File.file?(out_path) return { success: false, message: "#{engine} failed", exit_code: 1, stderr: "" } end { success: true, message: "", exit_code: 0, stderr: "" } rescue => e ::File.unlink(out_path) if out_path && ::File.file?(out_path) { success: false, message: e., exit_code: 1, stderr: "" } ensure ::File.unlink(temp_html) if temp_html && ::File.file?(temp_html) end end |
.load_document(root_dir:) ⇒ Object
54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 |
# File 'lib/rakit/markdown.rb', line 54 def self.load_document(root_dir:) path = root_dir.to_s raise ArgumentError, "root_dir does not exist: #{path}" unless ::File.exist?(path) raise ArgumentError, "root_dir is not a directory: #{path}" unless ::File.directory?(path) full_paths = discover_section_paths(path) root = ::File.(path) title = ::File.basename(root) sections = full_paths.map do |fp| rel = Pathname.new(fp).relative_path_from(Pathname.new(root)).to_s body = ::File.read(fp, encoding: "UTF-8") base = ::File.basename(fp, ".md") id = slug_from_title(base.sub(/\A\d+-/, "").tr("_", "-")) id = "section" if id.empty? Section.new(id: id, title: base, body: body, source_path: rel, sections: []) end Document.new(title: title, root_dir: path, sections: sections) end |
.load_outline_from_options(opts, href_mode: :file_anchor, site_base: nil) ⇒ Object
Load and normalize outline from options. Returns [outline, nil] or [nil, exit_code]. Errors written to stderr via yield or callers responsibility.
329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 |
# File 'lib/rakit/markdown.rb', line 329 def self.(opts, href_mode: :file_anchor, site_base: nil) input_type, path = resolve_renderer_input(opts) if input_type.nil? return [nil, EXIT_INVALID_INPUT] end path = ::File.(path.to_s.strip) outline = case input_type when :root outline_from_root(path) when :document_json json = ::File.read(path, encoding: "UTF-8") doc = decode_document_json(json) document_to_outline(doc) when :outline_json json = ::File.read(path, encoding: "UTF-8") decode_outline_json(json) end normalize_outline!(outline, href_mode: href_mode, site_base: site_base, dedupe: opts[:dedupe]) errs, _warn = validate_outline(outline, max_depth: opts[:max_depth], dedupe: opts[:dedupe], strict_paths: opts[:strict_paths]) return [nil, EXIT_VALIDATION] if errs.any? [outline, nil] rescue ArgumentError, Errno::ENOENT, Errno::EACCES => e $stderr.puts e. if $stderr [nil, EXIT_INVALID_INPUT] rescue ::JSON::ParserError => e $stderr.puts e. if $stderr [nil, EXIT_INVALID_INPUT] rescue => e $stderr.puts e. if $stderr [nil, EXIT_INTERNAL] end |
.merge(source_dir, target_path, create_directories: false) ⇒ Object
Merge: collect all .md under source_dir (sorted by path), write concatenated content to target_path. Target must be outside source_dir. Returns { success:, message:, exit_code: }.
475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 |
# File 'lib/rakit/markdown.rb', line 475 def self.merge(source_dir, target_path, create_directories: false) validated_dir = validate_dir_path(source_dir) unless validated_dir return { success: false, message: "Source directory must exist and be a directory", exit_code: 1 } end target_norm = normalize_path(target_path) unless target_norm return { success: false, message: "Target path is required", exit_code: 1 } end unless target_outside_source?(validated_dir, target_norm) return { success: false, message: "Target path must be outside source directory", exit_code: 1 } end parent = ::File.dirname(target_norm) unless ::File.directory?(parent) if create_directories ::FileUtils.mkdir_p(parent) else return { success: false, message: "Parent directory does not exist; use create_directories: true", exit_code: 1 } end end begin content = merge_collect(validated_dir) ::File.write(target_norm, content, encoding: "UTF-8") { success: true, message: "", exit_code: 0 } rescue => e ::File.unlink(target_norm) if ::File.file?(target_norm) { success: false, message: e., exit_code: 1 } end end |
.normalize_node!(node, href_mode: :file_anchor, site_base: nil, dedupe: false) ⇒ Object
208 209 210 211 212 213 214 |
# File 'lib/rakit/markdown.rb', line 208 def self.normalize_node!(node, href_mode: :file_anchor, site_base: nil, dedupe: false) node.children.each { |c| normalize_node!(c, href_mode: href_mode, site_base: site_base, dedupe: dedupe) } node.id = node.id.to_s.strip if node.id node.id = nil if node.id.to_s.empty? node.id = slug_for_outline(node.title) if node.id.to_s.empty? dedupe_sibling_ids!(node.children, dedupe: dedupe) end |
.normalize_node_href!(node, href_mode: :file_anchor, site_base: nil) ⇒ Object
216 217 218 219 |
# File 'lib/rakit/markdown.rb', line 216 def self.normalize_node_href!(node, href_mode: :file_anchor, site_base: nil) node.href = compute_href(node, href_mode: href_mode, site_base: site_base) if node.href.to_s.strip.empty? node.children.each { |c| normalize_node_href!(c, href_mode: href_mode, site_base: site_base) } end |
.normalize_outline!(outline, href_mode: :file_anchor, site_base: nil, dedupe: false) ⇒ Object
Apply slug (when id blank), sibling dedupe, and href. Modifies outline in place. dedupe: true = suffix -2,-3 and warn instead of validation error.
201 202 203 204 205 206 |
# File 'lib/rakit/markdown.rb', line 201 def self.normalize_outline!(outline, href_mode: :file_anchor, site_base: nil, dedupe: false) outline.nodes.each { |n| normalize_node!(n, href_mode: href_mode, site_base: site_base, dedupe: dedupe) } dedupe_sibling_ids!(outline.nodes, dedupe: dedupe) outline.nodes.each { |n| normalize_node_href!(n, href_mode: href_mode, site_base: site_base) } outline end |
.outline_from_root(root_dir) ⇒ Object
Load outline from root dir. If no .md files, returns Outline with empty nodes (valid for renderers).
289 290 291 292 293 294 295 296 297 298 299 300 |
# File 'lib/rakit/markdown.rb', line 289 def self.outline_from_root(root_dir) path = ::File.(root_dir.to_s) raise ArgumentError, "root_dir does not exist: #{root_dir}" unless ::File.exist?(path) raise ArgumentError, "root_dir is not a directory: #{root_dir}" unless ::File.directory?(path) paths = ::Dir.glob(::File.join(path, "**", "*.md")).reject { |p| ::File.basename(p).start_with?(".") }.sort title = ::File.basename(path) if paths.empty? return Outline.new(title: title, nodes: []) end doc = load_document(root_dir: root_dir) document_to_outline(doc) end |
.parse_headings_to_sections(content) ⇒ Object
Parse markdown by headings ^#\s; return array of { level, title, slug, body }.
116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 |
# File 'lib/rakit/markdown.rb', line 116 def self.parse_headings_to_sections(content) lines = content.to_s.lines heading_re = /\A(#+)\s+([^\n]*)\s*\z/ sections = [] current = { level: 0, title: nil, slug: nil, body: +"" } found_any = false lines.each do |line| m = line.match(heading_re) if m found_any = true level = m[1].length title = m[2].strip if current[:title] current[:body] = current[:body].sub(/\n+\z/, "") sections << { level: current[:level], title: current[:title], slug: slug_from_title(current[:title]), body: current[:body] } end current = { level: level, title: title, slug: slug_from_title(title), body: +"" } else current[:body] << line end end current[:body] = current[:body].sub(/\n+\z/, "") if current[:title] || !found_any sections << { level: current[:level], title: current[:title] || "Section", slug: (current[:title] ? slug_from_title(current[:title]) : "section"), body: current[:body] } end sections = [{ level: 0, title: "Section", slug: "section", body: content.to_s.strip }] if sections.empty? sections end |
.parse_renderer_args(argv) ⇒ Object
Parse argv for renderer commands; returns { root:, document_json:, outline_json:, max_depth:, dedupe:, strict_paths:, … }. Precedence for input: outline_json > document_json > root.
303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 |
# File 'lib/rakit/markdown.rb', line 303 def self.parse_renderer_args(argv) args = argv.dup opts = { root: nil, document_json: nil, outline_json: nil, max_depth: nil, dedupe: false, strict_paths: false } while (arg = args.shift) case arg when "--root" then opts[:root] = args.shift when "--document-json" then opts[:document_json] = args.shift when "--outline-json" then opts[:outline_json] = args.shift when "--max-depth" then opts[:max_depth] = args.shift when "--dedupe" then opts[:dedupe] = true when "--strict-paths" then opts[:strict_paths] = true end end opts[:max_depth] = opts[:max_depth].to_s =~ /\A\d+\z/ ? opts[:max_depth].to_i : nil opts end |
.pdf_available? ⇒ Boolean
True if a PDF engine (weasyprint or wkhtmltopdf) is available for generate_pdf.
549 550 551 |
# File 'lib/rakit/markdown.rb', line 549 def self.pdf_available? send(:pdf_engine_available?).nil? ? false : true end |
.render_outline(outline, max_depth: nil, show_ids: false, no_numbers: false) ⇒ Object
Render outline as numbered list (1., 1.1., …) or indented text. Indentation 2 spaces per depth.
428 429 430 431 432 |
# File 'lib/rakit/markdown.rb', line 428 def self.render_outline(outline, max_depth: nil, show_ids: false, no_numbers: false) buf = +"" render_outline_nodes(buf, outline.nodes, 1, max_depth: max_depth, show_ids: show_ids, no_numbers: no_numbers, indent: "", numbers: []) buf end |
.render_outline_nodes(buf, nodes, depth, max_depth: nil, show_ids: false, no_numbers: false, indent: "", numbers: []) ⇒ Object
434 435 436 437 438 439 440 441 442 443 444 445 446 |
# File 'lib/rakit/markdown.rb', line 434 def self.render_outline_nodes(buf, nodes, depth, max_depth: nil, show_ids: false, no_numbers: false, indent: "", numbers: []) return if max_depth && depth > max_depth nodes.each_with_index do |n, i| nums = numbers + [i + 1] num_str = no_numbers ? "" : nums.join(".") + ". " line = indent + num_str + n.title.to_s line << " (id: #{n.id})" if show_ids && n.id.to_s != "" line << "\n" buf << line child_indent = indent + " " render_outline_nodes(buf, n.children, depth + 1, max_depth: max_depth, show_ids: show_ids, no_numbers: no_numbers, indent: child_indent, numbers: nums) end end |
.render_toc(outline, max_depth: nil, no_links: false, href_mode: :file_anchor, site_base: nil, show_ids: false) ⇒ Object
Render outline as Markdown TOC (nested bullets, optional links). max_depth: nil = no limit; depth 1 = top-level.
380 381 382 383 384 |
# File 'lib/rakit/markdown.rb', line 380 def self.render_toc(outline, max_depth: nil, no_links: false, href_mode: :file_anchor, site_base: nil, show_ids: false) buf = +"" render_toc_nodes(buf, outline.nodes, 1, max_depth: max_depth, no_links: no_links, show_ids: show_ids, indent: "") buf end |
.render_toc_nodes(buf, nodes, depth, max_depth: nil, no_links: false, show_ids: false, indent: "") ⇒ Object
386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 |
# File 'lib/rakit/markdown.rb', line 386 def self.render_toc_nodes(buf, nodes, depth, max_depth: nil, no_links: false, show_ids: false, indent: "") return if max_depth && depth > max_depth nodes.each do |n| line = indent + "- " if !no_links && n.href.to_s.strip != "" line << "[#{n.title}](#{n.href})" else line << n.title.to_s end line << " (id: #{n.id})" if show_ids && n.id.to_s != "" line << "\n" buf << line render_toc_nodes(buf, n.children, depth + 1, max_depth: max_depth, no_links: no_links, show_ids: show_ids, indent: indent + " ") end end |
.render_tree(outline, max_depth: nil, show_ids: false, show_hrefs: false, ascii: false) ⇒ Object
Render outline as tree (box-drawing or ASCII). Root title printed above when non-empty.
403 404 405 406 407 408 |
# File 'lib/rakit/markdown.rb', line 403 def self.render_tree(outline, max_depth: nil, show_ids: false, show_hrefs: false, ascii: false) buf = +"" buf << outline.title.to_s << "\n" if outline.title.to_s.strip != "" render_tree_nodes(buf, outline.nodes, 1, max_depth: max_depth, show_ids: show_ids, show_hrefs: show_hrefs, ascii: ascii, prefix: "") buf end |
.render_tree_nodes(buf, nodes, depth, max_depth: nil, show_ids: false, show_hrefs: false, ascii: false, prefix: "") ⇒ Object
410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 |
# File 'lib/rakit/markdown.rb', line 410 def self.render_tree_nodes(buf, nodes, depth, max_depth: nil, show_ids: false, show_hrefs: false, ascii: false, prefix: "") return if max_depth && depth > max_depth return if nodes.empty? n_last = nodes.last nodes.each do |n| is_last = (n == n_last) branch = ascii ? (is_last ? "-- " : "|-- ") : (is_last ? "└─ " : "├─ ") line = prefix + branch + n.title.to_s line << " (id: #{n.id})" if show_ids && n.id.to_s != "" line << " → #{n.href}" if show_hrefs && n.href.to_s != "" line << "\n" buf << line child_prefix = prefix + (ascii ? (is_last ? " " : "| ") : (is_last ? " " : "│ ")) render_tree_nodes(buf, n.children, depth + 1, max_depth: max_depth, show_ids: show_ids, show_hrefs: show_hrefs, ascii: ascii, prefix: child_prefix) end end |
.resolve_renderer_input(opts) ⇒ Object
Resolve which input source from parsed opts (precedence: outline_json > document_json > root). Returns :outline_json, :document_json, or :root and the path value, or [nil, nil] if none.
321 322 323 324 325 326 |
# File 'lib/rakit/markdown.rb', line 321 def self.resolve_renderer_input(opts) return [:outline_json, opts[:outline_json]] if opts[:outline_json].to_s.strip != "" return [:document_json, opts[:document_json]] if opts[:document_json].to_s.strip != "" return [:root, opts[:root]] if opts[:root].to_s.strip != "" [nil, nil] end |
.section_to_outline_node(sec) ⇒ Object
190 191 192 193 194 195 196 197 198 |
# File 'lib/rakit/markdown.rb', line 190 def self.section_to_outline_node(sec) children = (sec.sections || []).map { |s| section_to_outline_node(s) } OutlineNode.new( title: sec.title.to_s, id: sec.id.to_s.strip.empty? ? nil : sec.id.to_s, source_path: sec.source_path.to_s.strip.empty? ? nil : sec.source_path.to_s, children: children ) end |
.slug_for_outline(title) ⇒ Object
Deterministic slug for outline node id: trim, lower-case, NFKD (when available), strip diacritics, non-→-, trim -, empty→“section”.
175 176 177 178 179 180 181 |
# File 'lib/rakit/markdown.rb', line 175 def self.slug_for_outline(title) return "section" if title.nil? || title.to_s.strip.empty? s = title.to_s.strip.downcase s = s.unicode_normalize(:nfkd).gsub(/\p{M}+/, "") if s.respond_to?(:unicode_normalize) s = s.gsub(/[^a-z0-9]+/, "-").gsub(/\A-+|-+\z/, "") s.empty? ? "section" : s end |
.slug_from_title(title) ⇒ Object
Derive slug from section title: lowercase, non-alphanumerics → ‘-’, collapse ‘-’, trim.
35 36 37 38 39 40 41 42 |
# File 'lib/rakit/markdown.rb', line 35 def self.slug_from_title(title) return "" if title.nil? || title.to_s.strip.empty? s = title.to_s.downcase s = s.gsub(/[^a-z0-9]+/, "-") s = s.gsub(/-+/, "-") s = s.sub(/\A-+/, "").sub(/-+\z/, "") s end |
.validate(root_dir:) ⇒ Object
150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 |
# File 'lib/rakit/markdown.rb', line 150 def self.validate(root_dir:) self.validation_failures = [] doc = load_document(root_dir: root_dir) ids = [] doc.sections.each do |sec| ids << sec.id end seen = {} ids.each do |id| if seen[id] self.validation_failures << "Duplicate section id: #{id}" else seen[id] = true end end return false if validation_failures.any? true rescue ArgumentError => e self.validation_failures = [e.] false end |
.validate_outline(outline, max_depth: nil, dedupe: false, strict_paths: false) ⇒ Object
Validate outline: empty title, duplicate explicit id among siblings, max_depth≤0. Returns [errors, warnings]. Zero nodes is valid.
256 257 258 259 260 261 262 263 264 |
# File 'lib/rakit/markdown.rb', line 256 def self.validate_outline(outline, max_depth: nil, dedupe: false, strict_paths: false) errors = [] warnings = [] if max_depth.is_a?(Integer) && max_depth <= 0 errors << "max_depth must be >= 1" end validate_outline_nodes!(outline.nodes, 1, errors, warnings, dedupe: dedupe, strict_paths: strict_paths) [errors, warnings] end |
.validate_outline_nodes!(nodes, depth, errors, warnings, dedupe: false, strict_paths: false) ⇒ Object
266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 |
# File 'lib/rakit/markdown.rb', line 266 def self.validate_outline_nodes!(nodes, depth, errors, warnings, dedupe: false, strict_paths: false) nodes.each do |n| if n.title.to_s.strip.empty? errors << "Node has empty title" end if strict_paths && n.source_path.to_s.strip != "" && (n.source_path.include?("..") || ::File.absolute_path?(n.source_path)) errors << "source_path must be relative: #{n.source_path}" end validate_outline_nodes!(n.children, depth + 1, errors, warnings, dedupe: dedupe, strict_paths: strict_paths) end return if dedupe ids = nodes.map { |n| n.id.to_s }.reject(&:empty?) seen = {} ids.each do |id| if seen[id] errors << "Duplicate id among siblings: #{id}" else seen[id] = true end end end |