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,151 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ace
4
+ module Support
5
+ module Markdown
6
+ module Molecules
7
+ # Build markdown documents programmatically
8
+ # Provides fluent API for creating documents from scratch
9
+ class DocumentBuilder
10
+ attr_reader :frontmatter, :sections
11
+
12
+ def initialize
13
+ @frontmatter = {}
14
+ @sections = []
15
+ end
16
+
17
+ # Set frontmatter fields
18
+ # @param data [Hash] Frontmatter data
19
+ # @return [DocumentBuilder] self for chaining
20
+ def frontmatter(data)
21
+ raise ArgumentError, "Frontmatter must be a hash" unless data.is_a?(Hash)
22
+
23
+ @frontmatter = @frontmatter.merge(data)
24
+ self
25
+ end
26
+
27
+ # Set a single frontmatter field
28
+ # @param key [String, Symbol] The field key
29
+ # @param value [Object] The field value
30
+ # @return [DocumentBuilder] self for chaining
31
+ def set_field(key, value)
32
+ @frontmatter[key.to_s] = value
33
+ self
34
+ end
35
+
36
+ # Add a section
37
+ # @param heading [String] The section heading
38
+ # @param content [String] The section content
39
+ # @param level [Integer] The heading level (default: 2)
40
+ # @return [DocumentBuilder] self for chaining
41
+ def add_section(heading:, content:, level: 2)
42
+ section = Models::Section.new(
43
+ heading: heading,
44
+ level: level,
45
+ content: content
46
+ )
47
+
48
+ @sections << section
49
+ self
50
+ end
51
+
52
+ # Add a title (level 1 heading)
53
+ # @param title [String] The title text
54
+ # @param content [String] Content under the title
55
+ # @return [DocumentBuilder] self for chaining
56
+ def title(title, content: "")
57
+ add_section(heading: title, content: content, level: 1)
58
+ end
59
+
60
+ # Add raw body content (without sections)
61
+ # @param content [String] The body content
62
+ # @return [DocumentBuilder] self for chaining
63
+ def body(content)
64
+ @body_content = content
65
+ self
66
+ end
67
+
68
+ # Build the document as a MarkdownDocument model
69
+ # @return [MarkdownDocument]
70
+ def build
71
+ body_text = if @sections.any?
72
+ # Build from sections
73
+ @sections.map(&:to_markdown).join("\n\n")
74
+ else
75
+ # Use raw body content
76
+ @body_content || ""
77
+ end
78
+
79
+ Models::MarkdownDocument.new(
80
+ frontmatter: @frontmatter,
81
+ raw_body: body_text,
82
+ sections: @sections.any? ? @sections : nil
83
+ )
84
+ end
85
+
86
+ # Build and convert to markdown string
87
+ # @return [String]
88
+ def to_markdown
89
+ build.to_markdown
90
+ end
91
+
92
+ # Validate the current builder state
93
+ # @return [Hash] Result with :valid, :errors
94
+ def validate
95
+ errors = []
96
+
97
+ if @frontmatter.empty?
98
+ errors << "No frontmatter defined"
99
+ end
100
+
101
+ if @sections.empty? && (@body_content.nil? || @body_content.empty?)
102
+ errors << "No content defined (neither sections nor body)"
103
+ end
104
+
105
+ {
106
+ valid: errors.empty?,
107
+ errors: errors
108
+ }
109
+ end
110
+
111
+ # Check if builder is valid
112
+ # @return [Boolean]
113
+ def valid?
114
+ validate[:valid]
115
+ end
116
+
117
+ # Create a builder from an existing document
118
+ # @param document [MarkdownDocument] The source document
119
+ # @return [DocumentBuilder]
120
+ def self.from_document(document)
121
+ raise ArgumentError, "Document must be a MarkdownDocument" unless document.is_a?(Models::MarkdownDocument)
122
+
123
+ builder = new
124
+ builder.frontmatter(document.frontmatter)
125
+
126
+ if document.has_sections?
127
+ document.sections.each do |section|
128
+ builder.add_section(
129
+ heading: section.heading,
130
+ content: section.content,
131
+ level: section.level
132
+ )
133
+ end
134
+ else
135
+ builder.body(document.raw_body)
136
+ end
137
+
138
+ builder
139
+ end
140
+
141
+ # Create a minimal document with just frontmatter
142
+ # @param frontmatter [Hash] The frontmatter data
143
+ # @return [MarkdownDocument]
144
+ def self.minimal(frontmatter)
145
+ new.frontmatter(frontmatter).build
146
+ end
147
+ end
148
+ end
149
+ end
150
+ end
151
+ end
@@ -0,0 +1,108 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "date"
4
+
5
+ module Ace
6
+ module Support
7
+ module Markdown
8
+ module Molecules
9
+ # Atomic frontmatter field updates for markdown documents
10
+ # Handles nested keys, special values, and immutable transformations
11
+ class FrontmatterEditor
12
+ # Update frontmatter fields in a document
13
+ # @param document [MarkdownDocument] The document to update
14
+ # @param updates [Hash] Fields to update (supports nested keys with dots)
15
+ # @return [MarkdownDocument] New document with updated frontmatter
16
+ def self.update(document, updates)
17
+ raise ArgumentError, "Document must be a MarkdownDocument" unless document.is_a?(Models::MarkdownDocument)
18
+ raise ArgumentError, "Updates must be a hash" unless updates.is_a?(Hash)
19
+
20
+ updated_frontmatter = apply_updates(document.frontmatter.dup, updates)
21
+
22
+ document.with_frontmatter(updated_frontmatter)
23
+ end
24
+
25
+ # Update a single field
26
+ # @param document [MarkdownDocument] The document to update
27
+ # @param key [String] The field key (supports dots for nesting)
28
+ # @param value [Object] The new value
29
+ # @return [MarkdownDocument] New document with updated field
30
+ def self.update_field(document, key, value)
31
+ update(document, {key => value})
32
+ end
33
+
34
+ # Delete a field from frontmatter
35
+ # @param document [MarkdownDocument] The document to update
36
+ # @param key [String] The field key to delete
37
+ # @return [MarkdownDocument] New document with field removed
38
+ def self.delete_field(document, key)
39
+ updated_frontmatter = document.frontmatter.dup
40
+ updated_frontmatter.delete(key)
41
+ updated_frontmatter.delete(key.to_sym) if key.is_a?(String)
42
+
43
+ document.with_frontmatter(updated_frontmatter)
44
+ end
45
+
46
+ # Merge multiple updates atomically
47
+ # @param document [MarkdownDocument] The document to update
48
+ # @param updates_list [Array<Hash>] List of update hashes
49
+ # @return [MarkdownDocument] New document with all updates applied
50
+ def self.merge_updates(document, updates_list)
51
+ raise ArgumentError, "Updates list must be an array" unless updates_list.is_a?(Array)
52
+
53
+ updates_list.reduce(document) do |doc, updates|
54
+ update(doc, updates)
55
+ end
56
+ end
57
+
58
+ private
59
+
60
+ # Apply updates to frontmatter hash (mutable operation on copy)
61
+ def self.apply_updates(frontmatter, updates)
62
+ updates.each do |key, value|
63
+ key_str = key.to_s
64
+
65
+ # Handle nested keys with dots (e.g., "update.last-updated")
66
+ if key_str.include?(".")
67
+ apply_nested_update(frontmatter, key_str, value)
68
+ else
69
+ frontmatter[key_str] = process_value(value)
70
+ end
71
+ end
72
+
73
+ frontmatter
74
+ end
75
+
76
+ # Apply update to nested key path
77
+ def self.apply_nested_update(frontmatter, key_path, value)
78
+ parts = key_path.split(".")
79
+ target = frontmatter
80
+
81
+ # Navigate to the target hash
82
+ parts[0...-1].each do |part|
83
+ target[part] ||= {}
84
+ target = target[part]
85
+ end
86
+
87
+ # Set the final value
88
+ target[parts.last] = process_value(value)
89
+ end
90
+
91
+ # Process special values before setting
92
+ def self.process_value(value)
93
+ case value
94
+ when "today"
95
+ Date.today.strftime("%Y-%m-%d")
96
+ when "now"
97
+ Time.now.strftime("%Y-%m-%d %H:%M:%S")
98
+ when /^\d{4}-\d{2}-\d{2}$/
99
+ value # Already a date string
100
+ else
101
+ value
102
+ end
103
+ end
104
+ end
105
+ end
106
+ end
107
+ end
108
+ end
@@ -0,0 +1,124 @@
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 Molecules
10
+ # Parse and serialize markdown using Kramdown
11
+ # Provides GFM-compatible markdown processing
12
+ class KramdownProcessor
13
+ # Parse markdown content to AST
14
+ # @param content [String] The markdown content
15
+ # @param options [Hash] Kramdown options
16
+ # @return [Hash] Result with :document, :success, :errors
17
+ def self.parse(content, options: {})
18
+ default_options = {
19
+ input: "GFM", # GitHub Flavored Markdown
20
+ hard_wrap: false,
21
+ auto_ids: true,
22
+ parse_block_html: true
23
+ }
24
+
25
+ merged_options = default_options.merge(options)
26
+
27
+ begin
28
+ document = Kramdown::Document.new(content, merged_options)
29
+
30
+ {
31
+ document: document,
32
+ success: true,
33
+ warnings: document.warnings || [],
34
+ errors: []
35
+ }
36
+ rescue => e
37
+ {
38
+ document: nil,
39
+ success: false,
40
+ warnings: [],
41
+ errors: ["Kramdown parsing error: #{e.message}"]
42
+ }
43
+ end
44
+ end
45
+
46
+ # Convert markdown AST back to markdown string
47
+ # @param document [Kramdown::Document] The kramdown document
48
+ # @return [Hash] Result with :markdown, :success, :errors
49
+ def self.to_markdown(document)
50
+ raise ArgumentError, "Document must be a Kramdown::Document" unless document.is_a?(Kramdown::Document)
51
+
52
+ begin
53
+ markdown = document.to_kramdown
54
+
55
+ {
56
+ markdown: markdown,
57
+ success: true,
58
+ errors: []
59
+ }
60
+ rescue => e
61
+ {
62
+ markdown: nil,
63
+ success: false,
64
+ errors: ["Kramdown serialization error: #{e.message}"]
65
+ }
66
+ end
67
+ end
68
+
69
+ # Round-trip: parse and convert back to markdown
70
+ # @param content [String] The markdown content
71
+ # @param options [Hash] Kramdown options
72
+ # @return [Hash] Result with :markdown, :success, :errors
73
+ def self.round_trip(content, options: {})
74
+ parse_result = parse(content, options: options)
75
+ return parse_result unless parse_result[:success]
76
+
77
+ to_markdown(parse_result[:document])
78
+ end
79
+
80
+ # Validate markdown can be parsed without errors
81
+ # @param content [String] The markdown content
82
+ # @return [Boolean] true if valid
83
+ def self.valid?(content)
84
+ result = parse(content)
85
+ result[:success] && result[:errors].empty?
86
+ end
87
+
88
+ # Extract headings from markdown
89
+ # @param content [String] The markdown content
90
+ # @return [Array<Hash>] Array of {:text, :level}
91
+ def self.extract_headings(content)
92
+ result = parse(content)
93
+ return [] unless result[:success]
94
+
95
+ find_headings(result[:document].root)
96
+ end
97
+
98
+ private
99
+
100
+ # Recursively find all headings in AST
101
+ def self.find_headings(element, headings = [])
102
+ if element.type == :header
103
+ text = element.children
104
+ .select { |c| c.type == :text }
105
+ .map(&:value)
106
+ .join
107
+
108
+ headings << {
109
+ text: text,
110
+ level: element.options[:level]
111
+ }
112
+ end
113
+
114
+ element.children.each do |child|
115
+ find_headings(child, headings)
116
+ end
117
+
118
+ headings
119
+ end
120
+ end
121
+ end
122
+ end
123
+ end
124
+ end
@@ -0,0 +1,158 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ace
4
+ module Support
5
+ module Markdown
6
+ module Molecules
7
+ # Edit document sections by heading
8
+ # Supports replace, append, delete operations using exact string matching
9
+ class SectionEditor
10
+ # Replace a section's content by heading
11
+ # @param document [MarkdownDocument] The document to update
12
+ # @param heading [String] The exact heading text to match
13
+ # @param new_content [String] The replacement content
14
+ # @return [MarkdownDocument] New document with replaced section
15
+ def self.replace_section(document, heading, new_content)
16
+ raise ArgumentError, "Document must be a MarkdownDocument" unless document.is_a?(Models::MarkdownDocument)
17
+
18
+ # Extract the current section to get its level
19
+ section_result = Atoms::SectionExtractor.extract(document.raw_body, heading)
20
+
21
+ unless section_result[:found]
22
+ raise SectionNotFoundError, "Section not found: #{heading}"
23
+ end
24
+
25
+ # Parse all sections
26
+ all_sections = Atoms::SectionExtractor.extract_all(document.raw_body)
27
+
28
+ # Find and replace the target section
29
+ new_sections = all_sections.map do |s|
30
+ if s[:heading] == heading
31
+ Models::Section.new(
32
+ heading: s[:heading],
33
+ level: s[:level],
34
+ content: new_content
35
+ )
36
+ else
37
+ Models::Section.new(
38
+ heading: s[:heading],
39
+ level: s[:level],
40
+ content: s[:content] || ""
41
+ )
42
+ end
43
+ end
44
+
45
+ document.with_sections(new_sections)
46
+ end
47
+
48
+ # Append content to a section
49
+ # @param document [MarkdownDocument] The document to update
50
+ # @param heading [String] The exact heading text to match
51
+ # @param additional_content [String] Content to append
52
+ # @return [MarkdownDocument] New document with updated section
53
+ def self.append_to_section(document, heading, additional_content)
54
+ raise ArgumentError, "Document must be a MarkdownDocument" unless document.is_a?(Models::MarkdownDocument)
55
+
56
+ # Extract current section
57
+ section_result = Atoms::SectionExtractor.extract(document.raw_body, heading)
58
+
59
+ unless section_result[:found]
60
+ raise SectionNotFoundError, "Section not found: #{heading}"
61
+ end
62
+
63
+ # Combine existing and new content
64
+ existing_content = section_result[:section_content] || ""
65
+ separator = existing_content.empty? ? "" : "\n\n"
66
+ new_content = "#{existing_content}#{separator}#{additional_content}"
67
+
68
+ # Replace with combined content
69
+ replace_section(document, heading, new_content)
70
+ end
71
+
72
+ # Delete a section
73
+ # @param document [MarkdownDocument] The document to update
74
+ # @param heading [String] The exact heading text to match
75
+ # @return [MarkdownDocument] New document without the section
76
+ def self.delete_section(document, heading)
77
+ raise ArgumentError, "Document must be a MarkdownDocument" unless document.is_a?(Models::MarkdownDocument)
78
+
79
+ # Parse all sections
80
+ all_sections = Atoms::SectionExtractor.extract_all(document.raw_body)
81
+
82
+ # Filter out the target section
83
+ remaining_sections = all_sections.reject { |s| s[:heading] == heading }
84
+
85
+ # Convert to Section models
86
+ new_sections = remaining_sections.map do |s|
87
+ Models::Section.new(
88
+ heading: s[:heading],
89
+ level: s[:level],
90
+ content: s[:content] || ""
91
+ )
92
+ end
93
+
94
+ document.with_sections(new_sections)
95
+ end
96
+
97
+ # Insert a new section before another section
98
+ # @param document [MarkdownDocument] The document to update
99
+ # @param before_heading [String] Insert before this heading
100
+ # @param new_section [Section] The section to insert
101
+ # @return [MarkdownDocument] New document with inserted section
102
+ def self.insert_section_before(document, before_heading, new_section)
103
+ raise ArgumentError, "Document must be a MarkdownDocument" unless document.is_a?(Models::MarkdownDocument)
104
+ raise ArgumentError, "New section must be a Section" unless new_section.is_a?(Models::Section)
105
+
106
+ # Parse all sections
107
+ all_sections = Atoms::SectionExtractor.extract_all(document.raw_body)
108
+
109
+ # Find insertion point
110
+ insert_index = all_sections.find_index { |s| s[:heading] == before_heading }
111
+
112
+ raise SectionNotFoundError, "Section not found: #{before_heading}" unless insert_index
113
+
114
+ # Convert existing sections to models
115
+ sections = all_sections.map do |s|
116
+ Models::Section.new(
117
+ heading: s[:heading],
118
+ level: s[:level],
119
+ content: s[:content] || ""
120
+ )
121
+ end
122
+
123
+ # Insert new section
124
+ sections.insert(insert_index, new_section)
125
+
126
+ document.with_sections(sections)
127
+ end
128
+
129
+ # Add a new section at the end
130
+ # @param document [MarkdownDocument] The document to update
131
+ # @param new_section [Section] The section to add
132
+ # @return [MarkdownDocument] New document with added section
133
+ def self.add_section(document, new_section)
134
+ raise ArgumentError, "Document must be a MarkdownDocument" unless document.is_a?(Models::MarkdownDocument)
135
+ raise ArgumentError, "New section must be a Section" unless new_section.is_a?(Models::Section)
136
+
137
+ # Parse all sections
138
+ all_sections = Atoms::SectionExtractor.extract_all(document.raw_body)
139
+
140
+ # Convert to models
141
+ sections = all_sections.map do |s|
142
+ Models::Section.new(
143
+ heading: s[:heading],
144
+ level: s[:level],
145
+ content: s[:content] || ""
146
+ )
147
+ end
148
+
149
+ # Add new section
150
+ sections << new_section
151
+
152
+ document.with_sections(sections)
153
+ end
154
+ end
155
+ end
156
+ end
157
+ end
158
+ end