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,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
|