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,190 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ace
4
+ module Support
5
+ module Markdown
6
+ module Organisms
7
+ # Main API for document editing with fluent interface
8
+ # Provides safe editing with state management and validation
9
+ class DocumentEditor
10
+ attr_reader :document, :file_path, :original_content
11
+
12
+ # Create a new DocumentEditor
13
+ # @param file_path [String] Path to the markdown file
14
+ def initialize(file_path)
15
+ raise ArgumentError, "File path cannot be nil" if file_path.nil?
16
+ raise FileOperationError, "File not found: #{file_path}" unless File.exist?(file_path)
17
+
18
+ @file_path = file_path
19
+ @original_content = File.read(file_path)
20
+ @document = Models::MarkdownDocument.parse(@original_content, file_path: file_path)
21
+ @backup_path = nil
22
+ end
23
+
24
+ # Update frontmatter fields
25
+ # @param updates [Hash] Fields to update
26
+ # @return [DocumentEditor] self for chaining
27
+ def update_frontmatter(updates)
28
+ @document = Molecules::FrontmatterEditor.update(@document, updates)
29
+ self
30
+ end
31
+
32
+ # Update a single frontmatter field
33
+ # @param key [String] The field key
34
+ # @param value [Object] The field value
35
+ # @return [DocumentEditor] self for chaining
36
+ def set_field(key, value)
37
+ @document = Molecules::FrontmatterEditor.update_field(@document, key, value)
38
+ self
39
+ end
40
+
41
+ # Replace a section's content
42
+ # @param heading [String] The section heading
43
+ # @param new_content [String] The new content
44
+ # @return [DocumentEditor] self for chaining
45
+ def replace_section(heading, new_content)
46
+ @document = Molecules::SectionEditor.replace_section(@document, heading, new_content)
47
+ self
48
+ end
49
+
50
+ # Append to a section
51
+ # @param heading [String] The section heading
52
+ # @param content [String] Content to append
53
+ # @return [DocumentEditor] self for chaining
54
+ def append_to_section(heading, content)
55
+ @document = Molecules::SectionEditor.append_to_section(@document, heading, content)
56
+ self
57
+ end
58
+
59
+ # Delete a section
60
+ # @param heading [String] The section heading
61
+ # @return [DocumentEditor] self for chaining
62
+ def delete_section(heading)
63
+ @document = Molecules::SectionEditor.delete_section(@document, heading)
64
+ self
65
+ end
66
+
67
+ # Add a new section
68
+ # @param heading [String] The section heading
69
+ # @param content [String] The section content
70
+ # @param level [Integer] The heading level (default: 2)
71
+ # @return [DocumentEditor] self for chaining
72
+ def add_section(heading, content, level: 2)
73
+ section = Models::Section.new(heading: heading, content: content, level: level)
74
+ @document = Molecules::SectionEditor.add_section(@document, section)
75
+ self
76
+ end
77
+
78
+ # Validate the current document state
79
+ # @param rules [Hash] Optional validation rules
80
+ # @return [Hash] Result with :valid, :errors, :warnings
81
+ def validate(rules: {})
82
+ content = @document.to_markdown
83
+ Atoms::DocumentValidator.validate(content, rules: rules)
84
+ end
85
+
86
+ # Check if document is valid
87
+ # @param rules [Hash] Optional validation rules
88
+ # @return [Boolean]
89
+ def valid?(rules: {})
90
+ validate(rules: rules)[:valid]
91
+ end
92
+
93
+ # Save the document to file
94
+ # @param backup [Boolean] Create backup before writing (default: true)
95
+ # @param validate_before [Boolean] Validate before writing (default: true)
96
+ # @param rules [Hash] Optional validation rules
97
+ # @return [Hash] Result with :success, :backup_path, :errors
98
+ def save!(backup: true, validate_before: true, rules: {})
99
+ # Validate before save
100
+ if validate_before
101
+ validation = validate(rules: rules)
102
+ unless validation[:valid]
103
+ return {
104
+ success: false,
105
+ backup_path: nil,
106
+ errors: validation[:errors]
107
+ }
108
+ end
109
+ end
110
+
111
+ # Generate new content
112
+ new_content = @document.to_markdown
113
+
114
+ # Use SafeFileWriter for atomic write with backup
115
+ result = SafeFileWriter.write(
116
+ @file_path,
117
+ new_content,
118
+ backup: backup
119
+ )
120
+
121
+ if result[:success]
122
+ @backup_path = result[:backup_path]
123
+ @original_content = new_content
124
+ end
125
+
126
+ result
127
+ end
128
+
129
+ # Rollback to original content
130
+ # @return [Hash] Result with :success, :errors
131
+ def rollback
132
+ if @backup_path && File.exist?(@backup_path)
133
+ begin
134
+ File.write(@file_path, File.read(@backup_path))
135
+ File.delete(@backup_path)
136
+ @backup_path = nil
137
+
138
+ # Reload document
139
+ @document = Models::MarkdownDocument.parse(@original_content, file_path: @file_path)
140
+
141
+ {success: true, errors: []}
142
+ rescue => e
143
+ {success: false, errors: ["Rollback failed: #{e.message}"]}
144
+ end
145
+ else
146
+ {success: false, errors: ["No backup available for rollback"]}
147
+ end
148
+ end
149
+
150
+ # Get current document content as string
151
+ # @return [String]
152
+ def to_markdown
153
+ @document.to_markdown
154
+ end
155
+
156
+ # Check if document has been modified
157
+ # @return [Boolean]
158
+ def modified?
159
+ @document.to_markdown != @original_content
160
+ end
161
+
162
+ # Get document statistics
163
+ # @return [Hash]
164
+ def stats
165
+ @document.stats
166
+ end
167
+
168
+ # Create a DocumentEditor from content string
169
+ # @param content [String] The markdown content
170
+ # @param file_path [String, nil] Optional file path for context
171
+ # @return [DocumentEditor]
172
+ def self.from_content(content, file_path: nil)
173
+ # Create a temporary file if needed
174
+ if file_path.nil?
175
+ require "tempfile"
176
+ temp = Tempfile.new(["markdown", ".md"])
177
+ temp.write(content)
178
+ temp.close
179
+ file_path = temp.path
180
+ else
181
+ File.write(file_path, content)
182
+ end
183
+
184
+ new(file_path)
185
+ end
186
+ end
187
+ end
188
+ end
189
+ end
190
+ end
@@ -0,0 +1,203 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "tempfile"
5
+
6
+ module Ace
7
+ module Support
8
+ module Markdown
9
+ module Organisms
10
+ # Safe file writing with backup and atomic operations
11
+ # Prevents corruption through temp file + move pattern
12
+ class SafeFileWriter
13
+ # Write content to file safely with backup and rollback
14
+ # @param file_path [String] Target file path
15
+ # @param content [String] Content to write
16
+ # @param backup [Boolean] Create backup before writing (default: true)
17
+ # @param validate [Boolean] Validate content before writing (default: false)
18
+ # @param validator [Proc, nil] Optional validation proc
19
+ # @return [Hash] Result with :success, :backup_path, :errors
20
+ def self.write(file_path, content, backup: true, validate: false, validator: nil)
21
+ raise ArgumentError, "File path cannot be nil" if file_path.nil?
22
+ raise ArgumentError, "Content cannot be nil" if content.nil?
23
+
24
+ errors = []
25
+
26
+ # Validate content if requested
27
+ if validate || validator
28
+ validation_errors = perform_validation(content, validator)
29
+ unless validation_errors.empty?
30
+ return {
31
+ success: false,
32
+ backup_path: nil,
33
+ errors: validation_errors
34
+ }
35
+ end
36
+ end
37
+
38
+ backup_path = nil
39
+
40
+ begin
41
+ # Create backup if requested and file exists
42
+ if backup && File.exist?(file_path)
43
+ backup_path = create_backup(file_path)
44
+ end
45
+
46
+ # Write atomically using temp file + move
47
+ write_atomic(file_path, content)
48
+
49
+ {
50
+ success: true,
51
+ backup_path: backup_path,
52
+ errors: []
53
+ }
54
+ rescue => e
55
+ # Rollback from backup if available
56
+ if backup_path && File.exist?(backup_path)
57
+ begin
58
+ FileUtils.cp(backup_path, file_path)
59
+ errors << "Write failed, restored from backup: #{e.message}"
60
+ rescue => rollback_error
61
+ errors << "Write failed and rollback failed: #{e.message} | #{rollback_error.message}"
62
+ end
63
+ else
64
+ errors << "Write failed: #{e.message}"
65
+ end
66
+
67
+ {
68
+ success: false,
69
+ backup_path: backup_path,
70
+ errors: errors
71
+ }
72
+ end
73
+ end
74
+
75
+ # Write content with automatic validation
76
+ # @param file_path [String] Target file path
77
+ # @param content [String] Content to write
78
+ # @param rules [Hash] Validation rules
79
+ # @return [Hash] Result with :success, :backup_path, :errors
80
+ def self.write_with_validation(file_path, content, rules: {})
81
+ validator = lambda do |c|
82
+ result = Atoms::DocumentValidator.validate(c, rules: rules)
83
+ result[:valid] ? [] : result[:errors]
84
+ end
85
+
86
+ write(file_path, content, backup: true, validate: true, validator: validator)
87
+ end
88
+
89
+ # Create a backup of the file
90
+ # @param file_path [String] File to backup
91
+ # @return [String] Backup file path
92
+ def self.create_backup(file_path)
93
+ raise FileOperationError, "File not found: #{file_path}" unless File.exist?(file_path)
94
+
95
+ timestamp = Time.now.strftime("%Y%m%d_%H%M%S_%L")
96
+ backup_path = "#{file_path}.backup.#{timestamp}"
97
+
98
+ FileUtils.cp(file_path, backup_path)
99
+ backup_path
100
+ end
101
+
102
+ # Restore from backup
103
+ # @param file_path [String] Target file path
104
+ # @param backup_path [String] Backup file path
105
+ # @return [Hash] Result with :success, :errors
106
+ def self.restore_from_backup(file_path, backup_path)
107
+ unless File.exist?(backup_path)
108
+ return {
109
+ success: false,
110
+ errors: ["Backup file not found: #{backup_path}"]
111
+ }
112
+ end
113
+
114
+ begin
115
+ FileUtils.cp(backup_path, file_path)
116
+ {
117
+ success: true,
118
+ errors: []
119
+ }
120
+ rescue => e
121
+ {
122
+ success: false,
123
+ errors: ["Restore failed: #{e.message}"]
124
+ }
125
+ end
126
+ end
127
+
128
+ # Cleanup old backup files
129
+ # @param file_path [String] Original file path
130
+ # @param keep [Integer] Number of recent backups to keep (default: 5)
131
+ # @return [Integer] Number of backups deleted
132
+ def self.cleanup_backups(file_path, keep: 5)
133
+ dir = File.dirname(file_path)
134
+ basename = File.basename(file_path)
135
+
136
+ # Find all backup files for this file
137
+ backup_pattern = File.join(dir, "#{basename}.backup.*")
138
+ backups = Dir.glob(backup_pattern).sort
139
+
140
+ # Keep only the most recent N backups
141
+ to_delete = backups[0...-keep] || []
142
+
143
+ deleted = 0
144
+ to_delete.each do |backup|
145
+ File.delete(backup)
146
+ deleted += 1
147
+ rescue
148
+ # Skip files that can't be deleted
149
+ next
150
+ end
151
+
152
+ deleted
153
+ end
154
+
155
+ private
156
+
157
+ # Write file atomically using temp file + move
158
+ def self.write_atomic(file_path, content)
159
+ # Get directory and ensure it exists
160
+ dir = File.dirname(file_path)
161
+ FileUtils.mkdir_p(dir) unless Dir.exist?(dir)
162
+
163
+ # Create temp file in same directory (same filesystem for atomic move)
164
+ temp = Tempfile.new([File.basename(file_path, ".*"), File.extname(file_path)], dir)
165
+
166
+ begin
167
+ # Write content to temp file
168
+ temp.write(content)
169
+ temp.close
170
+
171
+ # Atomic move (rename) - this is the critical operation
172
+ # On most filesystems, rename is atomic
173
+ FileUtils.mv(temp.path, file_path)
174
+ ensure
175
+ # Clean up temp file if it still exists
176
+ temp.close
177
+ temp.unlink if File.exist?(temp.path)
178
+ end
179
+ end
180
+
181
+ # Perform validation on content
182
+ def self.perform_validation(content, validator)
183
+ errors = []
184
+
185
+ # Basic validation - check content can be parsed
186
+ result = Atoms::FrontmatterExtractor.extract(content)
187
+ unless result[:valid]
188
+ errors.concat(result[:errors])
189
+ end
190
+
191
+ # Custom validator
192
+ if validator.is_a?(Proc)
193
+ custom_errors = validator.call(content)
194
+ errors.concat(custom_errors) if custom_errors.is_a?(Array)
195
+ end
196
+
197
+ errors
198
+ end
199
+ end
200
+ end
201
+ end
202
+ end
203
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ace
4
+ module Support
5
+ module Markdown
6
+ VERSION = "0.3.0"
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "markdown/version"
4
+
5
+ # Atoms
6
+ require_relative "markdown/atoms/frontmatter_extractor"
7
+ require_relative "markdown/atoms/frontmatter_serializer"
8
+ require_relative "markdown/atoms/section_extractor"
9
+ require_relative "markdown/atoms/document_validator"
10
+
11
+ # Molecules
12
+ require_relative "markdown/molecules/frontmatter_editor"
13
+ require_relative "markdown/molecules/section_editor"
14
+ require_relative "markdown/molecules/kramdown_processor"
15
+ require_relative "markdown/molecules/document_builder"
16
+
17
+ # Organisms
18
+ require_relative "markdown/organisms/document_editor"
19
+ require_relative "markdown/organisms/safe_file_writer"
20
+
21
+ # Models
22
+ require_relative "markdown/models/markdown_document"
23
+ require_relative "markdown/models/section"
24
+
25
+ module Ace
26
+ module Support
27
+ module Markdown
28
+ class Error < StandardError; end
29
+ class ValidationError < Error; end
30
+ class SectionNotFoundError < Error; end
31
+ class FileOperationError < Error; end
32
+ end
33
+ end
34
+ end
metadata ADDED
@@ -0,0 +1,148 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: ace-support-markdown
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.3.0
5
+ platform: ruby
6
+ authors:
7
+ - Michal Czyz
8
+ bindir: exe
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: kramdown
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '2.4'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: '2.4'
26
+ - !ruby/object:Gem::Dependency
27
+ name: kramdown-parser-gfm
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - "~>"
31
+ - !ruby/object:Gem::Version
32
+ version: '1.1'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - "~>"
38
+ - !ruby/object:Gem::Version
39
+ version: '1.1'
40
+ - !ruby/object:Gem::Dependency
41
+ name: bundler
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - "~>"
45
+ - !ruby/object:Gem::Version
46
+ version: '2.0'
47
+ type: :development
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - "~>"
52
+ - !ruby/object:Gem::Version
53
+ version: '2.0'
54
+ - !ruby/object:Gem::Dependency
55
+ name: minitest
56
+ requirement: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - "~>"
59
+ - !ruby/object:Gem::Version
60
+ version: '5.0'
61
+ type: :development
62
+ prerelease: false
63
+ version_requirements: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - "~>"
66
+ - !ruby/object:Gem::Version
67
+ version: '5.0'
68
+ - !ruby/object:Gem::Dependency
69
+ name: rake
70
+ requirement: !ruby/object:Gem::Requirement
71
+ requirements:
72
+ - - "~>"
73
+ - !ruby/object:Gem::Version
74
+ version: '13.0'
75
+ type: :development
76
+ prerelease: false
77
+ version_requirements: !ruby/object:Gem::Requirement
78
+ requirements:
79
+ - - "~>"
80
+ - !ruby/object:Gem::Version
81
+ version: '13.0'
82
+ - !ruby/object:Gem::Dependency
83
+ name: ace-support-test-helpers
84
+ requirement: !ruby/object:Gem::Requirement
85
+ requirements:
86
+ - - "~>"
87
+ - !ruby/object:Gem::Version
88
+ version: '0.12'
89
+ type: :development
90
+ prerelease: false
91
+ version_requirements: !ruby/object:Gem::Requirement
92
+ requirements:
93
+ - - "~>"
94
+ - !ruby/object:Gem::Version
95
+ version: '0.12'
96
+ description: Provides safe, atomic markdown file operations with frontmatter extraction,
97
+ section editing, and validation. Prevents file corruption through backup/rollback
98
+ mechanisms.
99
+ email:
100
+ - mc@cs3b.com
101
+ executables: []
102
+ extensions: []
103
+ extra_rdoc_files: []
104
+ files:
105
+ - CHANGELOG.md
106
+ - CONTRIBUTING.md
107
+ - LICENSE
108
+ - README.md
109
+ - Rakefile
110
+ - lib/ace/support/markdown.rb
111
+ - lib/ace/support/markdown/atoms/document_validator.rb
112
+ - lib/ace/support/markdown/atoms/frontmatter_extractor.rb
113
+ - lib/ace/support/markdown/atoms/frontmatter_serializer.rb
114
+ - lib/ace/support/markdown/atoms/section_extractor.rb
115
+ - lib/ace/support/markdown/models/markdown_document.rb
116
+ - lib/ace/support/markdown/models/section.rb
117
+ - lib/ace/support/markdown/molecules/document_builder.rb
118
+ - lib/ace/support/markdown/molecules/frontmatter_editor.rb
119
+ - lib/ace/support/markdown/molecules/kramdown_processor.rb
120
+ - lib/ace/support/markdown/molecules/section_editor.rb
121
+ - lib/ace/support/markdown/organisms/document_editor.rb
122
+ - lib/ace/support/markdown/organisms/safe_file_writer.rb
123
+ - lib/ace/support/markdown/version.rb
124
+ homepage: https://github.com/cs3b/ace
125
+ licenses:
126
+ - MIT
127
+ metadata:
128
+ homepage_uri: https://github.com/cs3b/ace
129
+ source_code_uri: https://github.com/cs3b/ace/tree/main/ace-support-markdown/
130
+ changelog_uri: https://github.com/cs3b/ace/blob/main/ace-support-markdown/CHANGELOG.md
131
+ rdoc_options: []
132
+ require_paths:
133
+ - lib
134
+ required_ruby_version: !ruby/object:Gem::Requirement
135
+ requirements:
136
+ - - ">="
137
+ - !ruby/object:Gem::Version
138
+ version: 3.2.0
139
+ required_rubygems_version: !ruby/object:Gem::Requirement
140
+ requirements:
141
+ - - ">="
142
+ - !ruby/object:Gem::Version
143
+ version: '0'
144
+ requirements: []
145
+ rubygems_version: 3.6.9
146
+ specification_version: 4
147
+ summary: Safe markdown editing with frontmatter support for ACE gems
148
+ test_files: []