ace-support-markdown 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.md +121 -0
- data/CONTRIBUTING.md +221 -0
- data/LICENSE +21 -0
- data/README.md +29 -0
- data/Rakefile +13 -0
- data/lib/ace/support/markdown/atoms/document_validator.rb +129 -0
- data/lib/ace/support/markdown/atoms/frontmatter_extractor.rb +117 -0
- data/lib/ace/support/markdown/atoms/frontmatter_serializer.rb +88 -0
- data/lib/ace/support/markdown/atoms/section_extractor.rb +174 -0
- data/lib/ace/support/markdown/models/markdown_document.rb +187 -0
- data/lib/ace/support/markdown/models/section.rb +114 -0
- data/lib/ace/support/markdown/molecules/document_builder.rb +151 -0
- data/lib/ace/support/markdown/molecules/frontmatter_editor.rb +108 -0
- data/lib/ace/support/markdown/molecules/kramdown_processor.rb +124 -0
- data/lib/ace/support/markdown/molecules/section_editor.rb +158 -0
- data/lib/ace/support/markdown/organisms/document_editor.rb +190 -0
- data/lib/ace/support/markdown/organisms/safe_file_writer.rb +203 -0
- data/lib/ace/support/markdown/version.rb +9 -0
- data/lib/ace/support/markdown.rb +34 -0
- metadata +148 -0
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "yaml"
|
|
4
|
+
|
|
5
|
+
module Ace
|
|
6
|
+
module Support
|
|
7
|
+
module Markdown
|
|
8
|
+
module Atoms
|
|
9
|
+
# Pure function to serialize frontmatter hash to YAML format
|
|
10
|
+
# Handles delimiter wrapping and YAML formatting
|
|
11
|
+
class FrontmatterSerializer
|
|
12
|
+
# Serialize frontmatter hash to YAML string with delimiters
|
|
13
|
+
# @param frontmatter [Hash] The frontmatter data
|
|
14
|
+
# @param body [String, nil] Optional body content to append
|
|
15
|
+
# @return [Hash] Result with :content (String), :valid (Boolean), :errors (Array)
|
|
16
|
+
def self.serialize(frontmatter, body: nil)
|
|
17
|
+
return empty_frontmatter_result if frontmatter.nil? || frontmatter.empty?
|
|
18
|
+
|
|
19
|
+
unless frontmatter.is_a?(Hash)
|
|
20
|
+
return {
|
|
21
|
+
content: "",
|
|
22
|
+
valid: false,
|
|
23
|
+
errors: ["Frontmatter must be a hash, got #{frontmatter.class}"]
|
|
24
|
+
}
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
begin
|
|
28
|
+
# Convert to YAML
|
|
29
|
+
yaml_content = YAML.dump(frontmatter).strip
|
|
30
|
+
|
|
31
|
+
# Remove leading --- that YAML.dump adds
|
|
32
|
+
yaml_content = yaml_content.sub(/^---\n/, "")
|
|
33
|
+
|
|
34
|
+
# Build the document
|
|
35
|
+
parts = ["---", yaml_content, "---"]
|
|
36
|
+
|
|
37
|
+
# Add body if provided
|
|
38
|
+
parts << "" << body.strip if body && !body.empty?
|
|
39
|
+
|
|
40
|
+
{
|
|
41
|
+
content: parts.join("\n"),
|
|
42
|
+
valid: true,
|
|
43
|
+
errors: []
|
|
44
|
+
}
|
|
45
|
+
rescue => e
|
|
46
|
+
{
|
|
47
|
+
content: "",
|
|
48
|
+
valid: false,
|
|
49
|
+
errors: ["YAML serialization error: #{e.message}"]
|
|
50
|
+
}
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Rebuild markdown document with frontmatter and body
|
|
55
|
+
# @param frontmatter [Hash] The frontmatter data
|
|
56
|
+
# @param body [String] The body content
|
|
57
|
+
# @return [String] The complete markdown document
|
|
58
|
+
def self.rebuild_document(frontmatter, body)
|
|
59
|
+
result = serialize(frontmatter, body: body)
|
|
60
|
+
raise ValidationError, result[:errors].join(", ") unless result[:valid]
|
|
61
|
+
|
|
62
|
+
result[:content]
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Serialize frontmatter only (without body)
|
|
66
|
+
# @param frontmatter [Hash] The frontmatter data
|
|
67
|
+
# @return [String] The frontmatter section with delimiters
|
|
68
|
+
def self.frontmatter_only(frontmatter)
|
|
69
|
+
result = serialize(frontmatter)
|
|
70
|
+
raise ValidationError, result[:errors].join(", ") unless result[:valid]
|
|
71
|
+
|
|
72
|
+
result[:content]
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
private
|
|
76
|
+
|
|
77
|
+
def self.empty_frontmatter_result
|
|
78
|
+
{
|
|
79
|
+
content: "---\n---",
|
|
80
|
+
valid: true,
|
|
81
|
+
errors: []
|
|
82
|
+
}
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "kramdown"
|
|
4
|
+
require "kramdown-parser-gfm"
|
|
5
|
+
|
|
6
|
+
module Ace
|
|
7
|
+
module Support
|
|
8
|
+
module Markdown
|
|
9
|
+
module Atoms
|
|
10
|
+
# Pure function to extract sections from markdown using Kramdown AST
|
|
11
|
+
# Supports exact string matching for section headings (v0.1.0)
|
|
12
|
+
class SectionExtractor
|
|
13
|
+
# Extract a section by heading text (exact string match)
|
|
14
|
+
# @param content [String] The markdown content (without frontmatter)
|
|
15
|
+
# @param heading_text [String] The exact heading text to match (e.g., "References")
|
|
16
|
+
# @return [Hash] Result with :section_content (String), :found (Boolean), :errors (Array)
|
|
17
|
+
def self.extract(content, heading_text)
|
|
18
|
+
return empty_result("Empty content") if content.nil? || content.empty?
|
|
19
|
+
return empty_result("Heading text required") if heading_text.nil? || heading_text.empty?
|
|
20
|
+
|
|
21
|
+
begin
|
|
22
|
+
# Parse markdown with Kramdown
|
|
23
|
+
doc = Kramdown::Document.new(content, input: "GFM")
|
|
24
|
+
|
|
25
|
+
# Find the target header in the AST
|
|
26
|
+
target_header, target_index = find_header(doc.root.children, heading_text)
|
|
27
|
+
|
|
28
|
+
unless target_header
|
|
29
|
+
return {
|
|
30
|
+
section_content: nil,
|
|
31
|
+
found: false,
|
|
32
|
+
errors: ["Section not found: #{heading_text}"]
|
|
33
|
+
}
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Extract content between this header and the next same-or-higher level header
|
|
37
|
+
section_content = extract_section_content(
|
|
38
|
+
doc.root.children,
|
|
39
|
+
target_index,
|
|
40
|
+
target_header.options[:level]
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
{
|
|
44
|
+
section_content: section_content,
|
|
45
|
+
found: true,
|
|
46
|
+
errors: []
|
|
47
|
+
}
|
|
48
|
+
rescue => e
|
|
49
|
+
{
|
|
50
|
+
section_content: nil,
|
|
51
|
+
found: false,
|
|
52
|
+
errors: ["Section extraction error: #{e.message}"]
|
|
53
|
+
}
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Extract all sections with their headings
|
|
58
|
+
# @param content [String] The markdown content
|
|
59
|
+
# @return [Array<Hash>] Array of {:heading, :level, :content}
|
|
60
|
+
def self.extract_all(content)
|
|
61
|
+
return [] if content.nil? || content.empty?
|
|
62
|
+
|
|
63
|
+
begin
|
|
64
|
+
doc = Kramdown::Document.new(content, input: "GFM")
|
|
65
|
+
headers = find_all_headers(doc.root.children)
|
|
66
|
+
|
|
67
|
+
headers.map.with_index do |header_info, idx|
|
|
68
|
+
# Extract content for each section
|
|
69
|
+
content_text = extract_section_content(
|
|
70
|
+
doc.root.children,
|
|
71
|
+
header_info[:index],
|
|
72
|
+
header_info[:level]
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
{
|
|
76
|
+
heading: header_info[:text],
|
|
77
|
+
level: header_info[:level],
|
|
78
|
+
content: content_text
|
|
79
|
+
}
|
|
80
|
+
end
|
|
81
|
+
rescue
|
|
82
|
+
[]
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
private
|
|
87
|
+
|
|
88
|
+
# Find a header element by exact text match
|
|
89
|
+
def self.find_header(elements, heading_text)
|
|
90
|
+
elements.each_with_index do |el, idx|
|
|
91
|
+
next unless el.type == :header
|
|
92
|
+
|
|
93
|
+
# Extract text from header children
|
|
94
|
+
text = el.children
|
|
95
|
+
.select { |c| c.type == :text }
|
|
96
|
+
.map(&:value)
|
|
97
|
+
.join
|
|
98
|
+
|
|
99
|
+
return [el, idx] if text == heading_text
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
nil
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# Find all headers in the document
|
|
106
|
+
def self.find_all_headers(elements)
|
|
107
|
+
headers = []
|
|
108
|
+
|
|
109
|
+
elements.each_with_index do |el, idx|
|
|
110
|
+
next unless el.type == :header
|
|
111
|
+
|
|
112
|
+
text = el.children
|
|
113
|
+
.select { |c| c.type == :text }
|
|
114
|
+
.map(&:value)
|
|
115
|
+
.join
|
|
116
|
+
|
|
117
|
+
headers << {
|
|
118
|
+
text: text,
|
|
119
|
+
level: el.options[:level],
|
|
120
|
+
index: idx
|
|
121
|
+
}
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
headers
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# Extract content elements between a header and the next same-or-higher level header
|
|
128
|
+
def self.extract_section_content(elements, start_index, level)
|
|
129
|
+
content_elements = []
|
|
130
|
+
|
|
131
|
+
# Collect elements after the header until next same-or-higher level header
|
|
132
|
+
((start_index + 1)...elements.length).each do |i|
|
|
133
|
+
el = elements[i]
|
|
134
|
+
|
|
135
|
+
# Stop if we hit another header of same or higher level
|
|
136
|
+
if el.type == :header && el.options[:level] <= level
|
|
137
|
+
break
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
content_elements << el
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# Convert elements back to markdown
|
|
144
|
+
elements_to_markdown(content_elements)
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# Convert Kramdown elements back to markdown string
|
|
148
|
+
def self.elements_to_markdown(elements)
|
|
149
|
+
return "" if elements.empty?
|
|
150
|
+
|
|
151
|
+
# Create a new document with these elements
|
|
152
|
+
temp_doc = Kramdown::Document.new("")
|
|
153
|
+
temp_root = temp_doc.root
|
|
154
|
+
temp_root.options[:encoding] = "UTF-8"
|
|
155
|
+
|
|
156
|
+
# Add elements to the new root
|
|
157
|
+
elements.each { |el| temp_root.children << el }
|
|
158
|
+
|
|
159
|
+
# Convert to markdown
|
|
160
|
+
temp_doc.to_kramdown.strip
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def self.empty_result(error_message)
|
|
164
|
+
{
|
|
165
|
+
section_content: nil,
|
|
166
|
+
found: false,
|
|
167
|
+
errors: [error_message]
|
|
168
|
+
}
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
end
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ace
|
|
4
|
+
module Support
|
|
5
|
+
module Markdown
|
|
6
|
+
module Models
|
|
7
|
+
# Immutable representation of a markdown document
|
|
8
|
+
# Contains frontmatter and sections for safe transformations
|
|
9
|
+
class MarkdownDocument
|
|
10
|
+
attr_reader :frontmatter, :sections, :raw_body, :file_path
|
|
11
|
+
|
|
12
|
+
# Create a new MarkdownDocument
|
|
13
|
+
# @param frontmatter [Hash] The YAML frontmatter data
|
|
14
|
+
# @param raw_body [String] The raw body content (without frontmatter)
|
|
15
|
+
# @param sections [Array<Section>] Optional parsed sections
|
|
16
|
+
# @param file_path [String, nil] Optional source file path
|
|
17
|
+
def initialize(frontmatter:, raw_body:, sections: nil, file_path: nil)
|
|
18
|
+
@frontmatter = frontmatter.freeze
|
|
19
|
+
@raw_body = raw_body.freeze
|
|
20
|
+
@sections = sections&.freeze
|
|
21
|
+
@file_path = file_path&.freeze
|
|
22
|
+
|
|
23
|
+
validate!
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Create a new document with updated frontmatter
|
|
27
|
+
# @param updates [Hash] Frontmatter updates to merge
|
|
28
|
+
# @return [MarkdownDocument] New document instance
|
|
29
|
+
def with_frontmatter(updates)
|
|
30
|
+
new_frontmatter = @frontmatter.merge(updates)
|
|
31
|
+
|
|
32
|
+
MarkdownDocument.new(
|
|
33
|
+
frontmatter: new_frontmatter,
|
|
34
|
+
raw_body: @raw_body,
|
|
35
|
+
sections: @sections,
|
|
36
|
+
file_path: @file_path
|
|
37
|
+
)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Create a new document with updated body
|
|
41
|
+
# @param new_body [String] The new body content
|
|
42
|
+
# @return [MarkdownDocument] New document instance
|
|
43
|
+
def with_body(new_body)
|
|
44
|
+
MarkdownDocument.new(
|
|
45
|
+
frontmatter: @frontmatter,
|
|
46
|
+
raw_body: new_body,
|
|
47
|
+
sections: nil, # Invalidate sections cache
|
|
48
|
+
file_path: @file_path
|
|
49
|
+
)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Create a new document with updated sections
|
|
53
|
+
# @param new_sections [Array<Section>] The new sections
|
|
54
|
+
# @return [MarkdownDocument] New document instance
|
|
55
|
+
def with_sections(new_sections)
|
|
56
|
+
# Rebuild raw_body from sections
|
|
57
|
+
new_body = new_sections.map(&:to_markdown).join("\n\n")
|
|
58
|
+
|
|
59
|
+
MarkdownDocument.new(
|
|
60
|
+
frontmatter: @frontmatter,
|
|
61
|
+
raw_body: new_body,
|
|
62
|
+
sections: new_sections,
|
|
63
|
+
file_path: @file_path
|
|
64
|
+
)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Get a specific frontmatter field
|
|
68
|
+
# @param key [String, Symbol] The field key
|
|
69
|
+
# @return [Object, nil] The field value
|
|
70
|
+
def get_frontmatter(key)
|
|
71
|
+
@frontmatter[key.to_s] || @frontmatter[key.to_sym]
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Find a section by heading text
|
|
75
|
+
# @param heading [String] The heading to find
|
|
76
|
+
# @return [Section, nil]
|
|
77
|
+
def find_section(heading)
|
|
78
|
+
return nil unless @sections
|
|
79
|
+
|
|
80
|
+
@sections.find { |s| s.heading == heading }
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Convert document to complete markdown string
|
|
84
|
+
# @return [String] The complete markdown document
|
|
85
|
+
def to_markdown
|
|
86
|
+
Atoms::FrontmatterSerializer.rebuild_document(@frontmatter, @raw_body)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Check if document has frontmatter
|
|
90
|
+
# @return [Boolean]
|
|
91
|
+
def has_frontmatter?
|
|
92
|
+
!@frontmatter.empty?
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Check if document has sections parsed
|
|
96
|
+
# @return [Boolean]
|
|
97
|
+
def has_sections?
|
|
98
|
+
!@sections.nil? && !@sections.empty?
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Get document statistics
|
|
102
|
+
# @return [Hash]
|
|
103
|
+
def stats
|
|
104
|
+
{
|
|
105
|
+
frontmatter_fields: @frontmatter.keys.length,
|
|
106
|
+
body_length: @raw_body.length,
|
|
107
|
+
sections_count: @sections&.length || 0,
|
|
108
|
+
word_count: @raw_body.split(/\s+/).length
|
|
109
|
+
}
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# Compare documents for equality
|
|
113
|
+
# @param other [MarkdownDocument]
|
|
114
|
+
# @return [Boolean]
|
|
115
|
+
def ==(other)
|
|
116
|
+
other.is_a?(MarkdownDocument) &&
|
|
117
|
+
@frontmatter == other.frontmatter &&
|
|
118
|
+
@raw_body == other.raw_body
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# Hash representation
|
|
122
|
+
# @return [Hash]
|
|
123
|
+
def to_h
|
|
124
|
+
{
|
|
125
|
+
frontmatter: @frontmatter,
|
|
126
|
+
raw_body: @raw_body,
|
|
127
|
+
sections: @sections&.map(&:to_h),
|
|
128
|
+
file_path: @file_path,
|
|
129
|
+
stats: stats
|
|
130
|
+
}
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# Parse document from markdown string
|
|
134
|
+
# @param content [String] The complete markdown content
|
|
135
|
+
# @param file_path [String, nil] Optional source file path
|
|
136
|
+
# @return [MarkdownDocument]
|
|
137
|
+
def self.parse(content, file_path: nil)
|
|
138
|
+
result = Atoms::FrontmatterExtractor.extract(content)
|
|
139
|
+
|
|
140
|
+
raise ValidationError, result[:errors].join(", ") unless result[:valid]
|
|
141
|
+
|
|
142
|
+
new(
|
|
143
|
+
frontmatter: result[:frontmatter],
|
|
144
|
+
raw_body: result[:body],
|
|
145
|
+
file_path: file_path
|
|
146
|
+
)
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# Parse document with sections
|
|
150
|
+
# @param content [String] The complete markdown content
|
|
151
|
+
# @param file_path [String, nil] Optional source file path
|
|
152
|
+
# @return [MarkdownDocument]
|
|
153
|
+
def self.parse_with_sections(content, file_path: nil)
|
|
154
|
+
doc = parse(content, file_path: file_path)
|
|
155
|
+
|
|
156
|
+
# Extract all sections
|
|
157
|
+
section_data = Atoms::SectionExtractor.extract_all(doc.raw_body)
|
|
158
|
+
|
|
159
|
+
sections = section_data.map do |s|
|
|
160
|
+
Section.new(
|
|
161
|
+
heading: s[:heading],
|
|
162
|
+
level: s[:level],
|
|
163
|
+
content: s[:content] || ""
|
|
164
|
+
)
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
doc.with_sections(sections)
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
private
|
|
171
|
+
|
|
172
|
+
def validate!
|
|
173
|
+
raise ArgumentError, "Frontmatter must be a hash" unless @frontmatter.is_a?(Hash)
|
|
174
|
+
raise ArgumentError, "Raw body cannot be nil" if @raw_body.nil?
|
|
175
|
+
|
|
176
|
+
if @sections
|
|
177
|
+
raise ArgumentError, "Sections must be an array" unless @sections.is_a?(Array)
|
|
178
|
+
unless @sections.all? { |s| s.is_a?(Section) }
|
|
179
|
+
raise ArgumentError, "All sections must be Section instances"
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
end
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ace
|
|
4
|
+
module Support
|
|
5
|
+
module Markdown
|
|
6
|
+
module Models
|
|
7
|
+
# Immutable representation of a markdown section
|
|
8
|
+
# Contains heading information and content
|
|
9
|
+
class Section
|
|
10
|
+
attr_reader :heading, :level, :content, :metadata
|
|
11
|
+
|
|
12
|
+
# Create a new Section
|
|
13
|
+
# @param heading [String] The section heading text
|
|
14
|
+
# @param level [Integer] The heading level (1-6)
|
|
15
|
+
# @param content [String] The section content (without heading)
|
|
16
|
+
# @param metadata [Hash] Optional metadata about the section
|
|
17
|
+
def initialize(heading:, level:, content:, metadata: {})
|
|
18
|
+
@heading = heading.freeze
|
|
19
|
+
@level = level.freeze
|
|
20
|
+
@content = content.freeze
|
|
21
|
+
@metadata = metadata.freeze
|
|
22
|
+
|
|
23
|
+
validate!
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Create a new Section with updated content
|
|
27
|
+
# @param new_content [String] The new content
|
|
28
|
+
# @return [Section] New Section instance
|
|
29
|
+
def with_content(new_content)
|
|
30
|
+
Section.new(
|
|
31
|
+
heading: @heading,
|
|
32
|
+
level: @level,
|
|
33
|
+
content: new_content,
|
|
34
|
+
metadata: @metadata
|
|
35
|
+
)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Create a new Section with updated heading
|
|
39
|
+
# @param new_heading [String] The new heading
|
|
40
|
+
# @return [Section] New Section instance
|
|
41
|
+
def with_heading(new_heading)
|
|
42
|
+
Section.new(
|
|
43
|
+
heading: new_heading,
|
|
44
|
+
level: @level,
|
|
45
|
+
content: @content,
|
|
46
|
+
metadata: @metadata
|
|
47
|
+
)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Create a new Section with updated metadata
|
|
51
|
+
# @param new_metadata [Hash] The new metadata
|
|
52
|
+
# @return [Section] New Section instance
|
|
53
|
+
def with_metadata(new_metadata)
|
|
54
|
+
Section.new(
|
|
55
|
+
heading: @heading,
|
|
56
|
+
level: @level,
|
|
57
|
+
content: @content,
|
|
58
|
+
metadata: new_metadata
|
|
59
|
+
)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Convert section to markdown string
|
|
63
|
+
# @return [String] The complete section as markdown
|
|
64
|
+
def to_markdown
|
|
65
|
+
heading_prefix = "#" * @level
|
|
66
|
+
"#{heading_prefix} #{@heading}\n\n#{@content}"
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Check if section is empty (no content)
|
|
70
|
+
# @return [Boolean]
|
|
71
|
+
def empty?
|
|
72
|
+
@content.nil? || @content.strip.empty?
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Get word count of section content
|
|
76
|
+
# @return [Integer]
|
|
77
|
+
def word_count
|
|
78
|
+
@content.split(/\s+/).length
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Compare sections for equality
|
|
82
|
+
# @param other [Section]
|
|
83
|
+
# @return [Boolean]
|
|
84
|
+
def ==(other)
|
|
85
|
+
other.is_a?(Section) &&
|
|
86
|
+
@heading == other.heading &&
|
|
87
|
+
@level == other.level &&
|
|
88
|
+
@content == other.content
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Hash representation
|
|
92
|
+
# @return [Hash]
|
|
93
|
+
def to_h
|
|
94
|
+
{
|
|
95
|
+
heading: @heading,
|
|
96
|
+
level: @level,
|
|
97
|
+
content: @content,
|
|
98
|
+
metadata: @metadata
|
|
99
|
+
}
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
private
|
|
103
|
+
|
|
104
|
+
def validate!
|
|
105
|
+
raise ArgumentError, "Heading cannot be nil or empty" if @heading.nil? || @heading.empty?
|
|
106
|
+
raise ArgumentError, "Level must be between 1 and 6" unless (1..6).cover?(@level)
|
|
107
|
+
raise ArgumentError, "Content cannot be nil" if @content.nil?
|
|
108
|
+
raise ArgumentError, "Metadata must be a hash" unless @metadata.is_a?(Hash)
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|