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.
@@ -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