hyraft 0.1.0.alpha1

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.
Files changed (84) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +5 -0
  3. data/CODE_OF_CONDUCT.md +132 -0
  4. data/LICENSE.txt +21 -0
  5. data/README.md +231 -0
  6. data/exe/hyraft +5 -0
  7. data/lib/hyraft/boot/asset_preloader.rb +185 -0
  8. data/lib/hyraft/boot/preloaded_static.rb +46 -0
  9. data/lib/hyraft/boot/preloader.rb +206 -0
  10. data/lib/hyraft/cli.rb +187 -0
  11. data/lib/hyraft/compiler/compiler.rb +34 -0
  12. data/lib/hyraft/compiler/html_purifier.rb +181 -0
  13. data/lib/hyraft/compiler/javascript_library.rb +281 -0
  14. data/lib/hyraft/compiler/javascript_obfuscator.rb +141 -0
  15. data/lib/hyraft/compiler/parser.rb +27 -0
  16. data/lib/hyraft/compiler/renderer.rb +217 -0
  17. data/lib/hyraft/engine/circuit.rb +35 -0
  18. data/lib/hyraft/engine/port.rb +17 -0
  19. data/lib/hyraft/engine/source.rb +19 -0
  20. data/lib/hyraft/engine.rb +11 -0
  21. data/lib/hyraft/router/api_router.rb +65 -0
  22. data/lib/hyraft/router/web_router.rb +136 -0
  23. data/lib/hyraft/system_info.rb +26 -0
  24. data/lib/hyraft/version.rb +5 -0
  25. data/lib/hyraft.rb +48 -0
  26. data/templates/do_app/Gemfile +50 -0
  27. data/templates/do_app/Rakefile +88 -0
  28. data/templates/do_app/adapter-intake/web-app/display/pages/home/home.hyr +174 -0
  29. data/templates/do_app/adapter-intake/web-app/request/home_web_adapter.rb +19 -0
  30. data/templates/do_app/boot.rb +41 -0
  31. data/templates/do_app/framework/adapters/server/server_api_adapter.rb +51 -0
  32. data/templates/do_app/framework/adapters/server/server_web_adapter.rb +178 -0
  33. data/templates/do_app/framework/compiler/style_resolver.rb +33 -0
  34. data/templates/do_app/framework/errors/error_handler.rb +75 -0
  35. data/templates/do_app/framework/errors/templates/304.html +22 -0
  36. data/templates/do_app/framework/errors/templates/400.html +22 -0
  37. data/templates/do_app/framework/errors/templates/401.html +22 -0
  38. data/templates/do_app/framework/errors/templates/403.html +22 -0
  39. data/templates/do_app/framework/errors/templates/404.html +62 -0
  40. data/templates/do_app/framework/errors/templates/500.html +73 -0
  41. data/templates/do_app/framework/middleware/cors_middleware.rb +37 -0
  42. data/templates/do_app/infra/config/environment.rb +86 -0
  43. data/templates/do_app/infra/config/error_config.rb +80 -0
  44. data/templates/do_app/infra/config/routes/api_routes.rb +2 -0
  45. data/templates/do_app/infra/config/routes/web_routes.rb +10 -0
  46. data/templates/do_app/infra/database/sequel_connection.rb +62 -0
  47. data/templates/do_app/infra/gems/database.rb +7 -0
  48. data/templates/do_app/infra/gems/load_all.rb +4 -0
  49. data/templates/do_app/infra/gems/utilities.rb +1 -0
  50. data/templates/do_app/infra/gems/web.rb +3 -0
  51. data/templates/do_app/infra/server/api-server.ru +13 -0
  52. data/templates/do_app/infra/server/web-server.ru +32 -0
  53. data/templates/do_app/package.json +9 -0
  54. data/templates/do_app/public/favicon.ico +0 -0
  55. data/templates/do_app/public/icons/docs.svg +10 -0
  56. data/templates/do_app/public/icons/expli.svg +13 -0
  57. data/templates/do_app/public/icons/git-repo.svg +13 -0
  58. data/templates/do_app/public/icons/hexagonal-arch.svg +15 -0
  59. data/templates/do_app/public/icons/template-engine.svg +26 -0
  60. data/templates/do_app/public/images/hyr-logo.png +0 -0
  61. data/templates/do_app/public/images/hyr-logo.webp +0 -0
  62. data/templates/do_app/public/index.html +22 -0
  63. data/templates/do_app/public/styles/css/main.css +418 -0
  64. data/templates/do_app/public/styles/css/spa.css +171 -0
  65. data/templates/do_app/shared/helpers/pagination_helper.rb +44 -0
  66. data/templates/do_app/shared/helpers/response_formatter.rb +25 -0
  67. data/templates/do_app/test/acceptance/api/articles_api_acceptance_test.rb +43 -0
  68. data/templates/do_app/test/acceptance/web/articles_acceptance_test.rb +31 -0
  69. data/templates/do_app/test/acceptance/web/home_acceptance_test.rb +17 -0
  70. data/templates/do_app/test/db.rb +106 -0
  71. data/templates/do_app/test/integration/adapter-exhaust/data-gateway/sequel_articles_gateway_test.rb +79 -0
  72. data/templates/do_app/test/integration/adapter-intake/api-app/request/articles_api_adapter_test.rb +61 -0
  73. data/templates/do_app/test/integration/adapter-intake/web-app/request/articles_web_adapter_test.rb +20 -0
  74. data/templates/do_app/test/integration/adapter-intake/web-app/request/home_web_adapter_test.rb +17 -0
  75. data/templates/do_app/test/integration/database/migration_test.rb +35 -0
  76. data/templates/do_app/test/support/mock_api_adapter.rb +82 -0
  77. data/templates/do_app/test/support/mock_articles_gateway.rb +41 -0
  78. data/templates/do_app/test/support/mock_web_adapter.rb +85 -0
  79. data/templates/do_app/test/support/test_patches.rb +33 -0
  80. data/templates/do_app/test/test_helper.rb +526 -0
  81. data/templates/do_app/test/unit/engine/circuit/articles_circuit_test.rb +167 -0
  82. data/templates/do_app/test/unit/engine/port/articles_gateway_port_test.rb +12 -0
  83. data/templates/do_app/test/unit/engine/source/article_test.rb +37 -0
  84. metadata +291 -0
@@ -0,0 +1,206 @@
1
+ # framework/boot/preloader.rb - Complete fixed version
2
+ module Hyraft
3
+ module Preloader
4
+ COLORS = {
5
+ green: "\e[32m",
6
+ cyan: "\e[36m",
7
+ yellow: "\e[33m",
8
+ red: "\e[31m",
9
+ orange: "\e[38;5;214m",
10
+ blue: "\e[34m",
11
+ lightblue: "\e[94m",
12
+ reset: "\e[0m"
13
+ }
14
+
15
+ @preloaded_templates = {}
16
+ @layout_content = nil
17
+ @stats = {
18
+ total_templates: 0,
19
+ total_bytes: 0,
20
+ compiled_bytes: 0,
21
+ load_time: 0.0
22
+ }
23
+
24
+ def self.preload_templates
25
+ puts "#{COLORS[:green]} Hyraft Preloader: Scanning for templates...#{COLORS[:reset]}"
26
+
27
+ start_time = Time.now
28
+
29
+ # Load layout first
30
+ layout_file = File.join(ROOT, 'public', 'index.html')
31
+ @layout_content = File.read(layout_file) if File.exist?(layout_file)
32
+
33
+ templates_found = discover_templates
34
+ puts "Found #{templates_found.size} template files"
35
+
36
+ templates_found.each do |file_path|
37
+ preload_template(file_path)
38
+ end
39
+
40
+ @stats[:load_time] = Time.now - start_time
41
+ print_stats
42
+ end
43
+
44
+ def self.discover_templates
45
+ Dir.glob(File.join('adapter-intake', '**', '*.hyr')).sort
46
+ end
47
+
48
+ def self.preload_template(file_path)
49
+ template_key = file_path
50
+ .delete_prefix('adapter-intake/')
51
+ .sub(/\.hyr$/, '')
52
+
53
+ begin
54
+ content = File.read(file_path)
55
+ sections = extract_sections(content)
56
+
57
+ # ONLY pre-compile templates WITHOUT transmuter code (pure HTML templates)
58
+ compiled_html = nil
59
+ compile_success = false
60
+ compile_error = nil
61
+
62
+ if @layout_content && sections[:transmuter].to_s.strip.empty?
63
+ begin
64
+ # Only compile templates that don't have Ruby code in transmuter
65
+ renderer = Hyraft::Compiler::HyraftRenderer.new
66
+
67
+ # For pure HTML templates, we can safely compile with empty locals
68
+ # The [.variable.] placeholders will remain as-is for runtime replacement
69
+ compiled_html = renderer.render(@layout_content.dup, sections, {})
70
+ compile_success = true
71
+
72
+ rescue => e
73
+ compile_error = e.message
74
+ end
75
+ else
76
+ compile_error = "has transmuter code" if sections[:transmuter] && !sections[:transmuter].empty?
77
+ end
78
+
79
+ @preloaded_templates[template_key] = {
80
+ sections: sections,
81
+ compiled_html: compiled_html,
82
+ compile_success: compile_success,
83
+ compile_error: compile_error,
84
+ bytesize: content.bytesize,
85
+ compiled_bytes: compiled_html&.bytesize || 0,
86
+ mtime: File.mtime(file_path),
87
+ full_path: file_path
88
+ }
89
+
90
+ @stats[:total_templates] += 1
91
+ @stats[:total_bytes] += content.bytesize
92
+ @stats[:compiled_bytes] += compiled_html&.bytesize || 0
93
+
94
+ if compile_success
95
+ puts " #{COLORS[:green]}✓#{COLORS[:reset]} #{COLORS[:lightblue]}#{template_key}#{COLORS[:reset]}#{COLORS[:orange]}.hyr#{COLORS[:reset]} (#{COLORS[:yellow]}#{(compiled_html.bytesize / 1024.0).round(2)} KB#{COLORS[:reset]})"
96
+ elsif compile_error == "has transmuter code"
97
+ puts " #{COLORS[:blue]}ℹ#{COLORS[:reset]} #{COLORS[:lightblue]}#{template_key}#{COLORS[:reset]}#{COLORS[:orange]}.hyr#{COLORS[:reset]} (#{COLORS[:cyan]}dynamic template#{COLORS[:reset]})"
98
+ else
99
+ puts " #{COLORS[:yellow]}⚠#{COLORS[:reset]} #{COLORS[:lightblue]}#{template_key}#{COLORS[:reset]}#{COLORS[:orange]}.hyr#{COLORS[:reset]} (#{COLORS[:red]}sections only#{COLORS[:reset]})"
100
+ end
101
+
102
+ rescue => e
103
+ puts " #{COLORS[:red]}✗#{COLORS[:reset]} #{COLORS[:cyan]}#{template_key}#{COLORS[:reset]}#{COLORS[:green]}.hyr#{COLORS[:reset]} #{COLORS[:red]}Error: #{e.message}#{COLORS[:reset]}"
104
+ end
105
+ end
106
+
107
+ def self.get_template(template_key)
108
+ @preloaded_templates[template_key]
109
+ end
110
+
111
+ def self.template_preloaded?(template_key)
112
+ @preloaded_templates.key?(template_key)
113
+ end
114
+
115
+ def self.render_template(template_key, locals = {})
116
+ return "<h1>Template not found: #{template_key}</h1>" unless template_preloaded?(template_key)
117
+
118
+ template_data = get_template(template_key)
119
+
120
+ # ULTRA FAST PATH: Use pre-compiled HTML (only for templates without transmuter)
121
+ if template_data[:compiled_html] && template_data[:compile_success]
122
+ return apply_locals_to_compiled(template_data[:compiled_html].dup, locals)
123
+ end
124
+
125
+ # FAST PATH: Use preloaded sections with HyraftRenderer (for dynamic templates)
126
+ layout_file = File.join(ROOT, 'public', 'index.html')
127
+ layout_content = File.read(layout_file) if File.exist?(layout_file)
128
+
129
+ renderer = Hyraft::Compiler::HyraftRenderer.new
130
+ renderer.render(layout_content, template_data[:sections], locals)
131
+ end
132
+
133
+ # FAST: Apply locals to pre-compiled HTML (only for placeholders, no Ruby execution)
134
+ def self.apply_locals_to_compiled(compiled_html, locals)
135
+ return compiled_html if locals.empty?
136
+
137
+ # Simple string replacement for [.variable.] placeholders
138
+ locals.each do |key, value|
139
+ placeholder = "[.#{key}.]"
140
+ compiled_html = compiled_html.gsub(placeholder, value.to_s)
141
+ end
142
+ compiled_html
143
+ end
144
+
145
+ def self.stats
146
+ @stats.dup
147
+ end
148
+
149
+ private
150
+
151
+ def self.extract_sections(content)
152
+ {
153
+ metadata: extract_section(content, 'metadata'),
154
+ displayer: extract_section(content, 'displayer'),
155
+ transmuter: extract_section(content, 'transmuter'),
156
+ manifestor: extract_section(content, 'manifestor'),
157
+ styles: extract_styles(content)
158
+ }
159
+ end
160
+
161
+ def self.extract_section(content, section_name)
162
+ match = content.match(/<#{section_name}.*?>(.*?)<\/#{section_name}>/m)
163
+ match ? match[1].strip : ''
164
+ end
165
+
166
+ def self.extract_styles(content)
167
+ content.scan(/<style[^>]*href="([^"]*)"/).flatten
168
+ end
169
+
170
+ def self.print_stats
171
+ compiled_count = @preloaded_templates.count { |_, t| t[:compile_success] }
172
+ dynamic_count = @preloaded_templates.count { |_, t| t[:compile_error] == "has transmuter code" }
173
+ sections_count = @preloaded_templates.count - compiled_count - dynamic_count
174
+
175
+ puts "\n#{COLORS[:green]}Preload Statistics:#{COLORS[:reset]}"
176
+ puts " #{COLORS[:cyan]}Templates:#{COLORS[:reset]} #{@stats[:total_templates]} (#{compiled_count} pre-compiled, #{dynamic_count} dynamic, #{sections_count} sections only)"
177
+ puts " #{COLORS[:cyan]}Source Size:#{COLORS[:reset]} #{(@stats[:total_bytes] / 1024.0).round(2)} KB"
178
+ puts " #{COLORS[:cyan]}Compiled Size:#{COLORS[:reset]} #{(@stats[:compiled_bytes] / 1024.0).round(2)} KB"
179
+ puts " #{COLORS[:cyan]}Load Time:#{COLORS[:reset]} #{@stats[:load_time].round(3)}s"
180
+ puts " #{COLORS[:cyan]}Memory:#{COLORS[:reset]} #{memory_usage} MB"
181
+
182
+ if compiled_count > 0
183
+ puts " #{COLORS[:cyan]}Status:#{COLORS[:reset]} #{COLORS[:green]}PARTIALLY PRE-COMPILED#{COLORS[:reset]}\n\n"
184
+ else
185
+ puts " #{COLORS[:cyan]}Status:#{COLORS[:reset]} #{COLORS[:yellow]}SECTIONS ONLY#{COLORS[:reset]}\n\n"
186
+ end
187
+ end
188
+
189
+ def self.memory_usage
190
+ if Gem.win_platform?
191
+ # Windows (no `ps -o rss=`)
192
+ get_windows_memory
193
+ else
194
+ # Linux/Mac
195
+ (`ps -o rss= -p #{Process.pid}`.to_i / 1024.0).round(2)
196
+ end
197
+ end
198
+
199
+ def self.get_windows_memory
200
+ memory_kb = `tasklist /FI "PID eq #{Process.pid}" /FO CSV /NH`
201
+ .split(",")[4].to_s.gsub('"','').gsub(/[^0-9]/, '').to_i
202
+ (memory_kb / 1024.0).round(2)
203
+ end
204
+
205
+ end
206
+ end
data/lib/hyraft/cli.rb ADDED
@@ -0,0 +1,187 @@
1
+ # frozen_string_literal: true
2
+ # hyraft/lib/hyraft/cli.rb
3
+
4
+ require 'fileutils'
5
+ require 'set'
6
+
7
+ module Hyraft
8
+ class CLI
9
+ def self.start(argv)
10
+ new(argv).execute
11
+ end
12
+
13
+ def initialize(argv)
14
+ @argv = argv
15
+ @command = argv[0]
16
+ @app_name = argv[1]
17
+ end
18
+
19
+ def execute
20
+ case @command
21
+ when "do"
22
+ do_application
23
+ when "version", "-v", "--version"
24
+ puts "Hyraft version #{VERSION}"
25
+ when nil, "help", "-h", "--help"
26
+ show_help
27
+ else
28
+ puts "Unknown command: #{@command}"
29
+ show_help
30
+ end
31
+ end
32
+
33
+ private
34
+
35
+ def show_help
36
+ puts "Hyraft - Hexagonal Architecture Framework"
37
+ puts "Commands:"
38
+ puts " set APP_NAME Create a new Hyraft application"
39
+ puts " version Show version information"
40
+ puts " help Show this help message"
41
+ end
42
+
43
+ def do_application
44
+ return puts "Error: Need app name" unless @app_name
45
+
46
+ template_dir = find_template_dir
47
+ unless template_dir && Dir.exist?(template_dir)
48
+ puts "Error: Could not find template directory"
49
+ exit 1
50
+ end
51
+
52
+ target_dir = File.expand_path(@app_name)
53
+
54
+ if Dir.exist?(target_dir)
55
+ puts "Error: Directory '#{@app_name}' already exists"
56
+ exit 1
57
+ end
58
+
59
+ puts "Creating Hyraft application: #{@app_name}"
60
+ copy_template_structure(template_dir, target_dir)
61
+ ensure_hexagonal_structure(target_dir)
62
+ create_env_file(target_dir)
63
+ puts "✅ Hyraft app '#{@app_name}' created successfully!"
64
+ puts " cd #{@app_name}"
65
+ puts " bundle install && npm install"
66
+ puts " hyraft-server thin"
67
+ end
68
+
69
+ def find_template_dir
70
+ locations = [
71
+ -> {
72
+ gem_spec = Gem::Specification.find_by_name('hyraft')
73
+ File.join(gem_spec.gem_dir, 'templates', 'do_app')
74
+ },
75
+ -> { File.expand_path('../../templates/do_app', __dir__) },
76
+ -> { File.expand_path('templates/do_app', Dir.pwd) }
77
+ ]
78
+
79
+ locations.each do |location_proc|
80
+ begin
81
+ dir = location_proc.call
82
+ return dir if Dir.exist?(dir)
83
+ rescue Gem::LoadError, StandardError
84
+ next
85
+ end
86
+ end
87
+
88
+ nil
89
+ end
90
+
91
+ def copy_template_structure(source, destination)
92
+ FileUtils.mkdir_p(destination)
93
+ copy_entire_structure(source, destination)
94
+ end
95
+
96
+ def copy_entire_structure(source, destination)
97
+ require 'find'
98
+ copied_paths = Set.new
99
+
100
+ Find.find(source) do |path|
101
+ next if path == source
102
+ relative_path = path.sub("#{source}/", '')
103
+ target_path = File.join(destination, relative_path)
104
+ next if copied_paths.include?(relative_path)
105
+ copied_paths.add(relative_path)
106
+
107
+ if File.directory?(path)
108
+ FileUtils.mkdir_p(target_path)
109
+ puts " create #{target_path}/" unless Dir.empty?(path)
110
+ else
111
+ FileUtils.mkdir_p(File.dirname(target_path))
112
+ FileUtils.cp(path, target_path)
113
+ puts " create #{target_path}"
114
+ end
115
+ end
116
+ end
117
+
118
+ def ensure_hexagonal_structure(app_dir)
119
+ hex_dirs = [
120
+ 'engine/circuit',
121
+ 'engine/port',
122
+ 'engine/source',
123
+ 'adapter-intake/api-app/request',
124
+ 'adapter-intake/web-app/request',
125
+ 'adapter-exhaust/data-gateway/',
126
+ 'infra/database/migrations',
127
+ 'public/uploads',
128
+ ]
129
+
130
+ puts "Ensuring hexagonal architecture structure..."
131
+
132
+ hex_dirs.each do |dir|
133
+ full_path = File.join(app_dir, dir)
134
+ unless Dir.exist?(full_path)
135
+ FileUtils.mkdir_p(full_path)
136
+ puts " create #{full_path}/" if Dir.empty?(full_path)
137
+ end
138
+ end
139
+ end
140
+
141
+ def create_env_file(app_dir)
142
+ env_path = File.join(app_dir, 'env.yml')
143
+
144
+ env_content = <<~YAML
145
+ # env.yml
146
+
147
+ # Application Settings
148
+
149
+ APP_NAME: myapp
150
+ SERVER_PORT: 1091
151
+ SERVER_PORT_API: 1092
152
+
153
+ development:
154
+ DB_CONNECTION: mysql
155
+ DB_HOST: localhost
156
+ DB_PORT: 3306
157
+ DB_DATABASE: myapp_development
158
+ DB_USERNAME: root
159
+ DB_PASSWORD:
160
+ DB_CHARSET: utf8mb4
161
+
162
+
163
+ test:
164
+ DB_CONNECTION: mysql
165
+ DB_HOST: localhost
166
+ DB_PORT: 3306
167
+ DB_DATABASE: myapp_test
168
+ DB_USERNAME: root
169
+ DB_PASSWORD:
170
+ DB_CHARSET: utf8mb4
171
+
172
+
173
+ production:
174
+ DB_CONNECTION: mysql
175
+ DB_HOST: localhost
176
+ DB_PORT: 3306
177
+ DB_DATABASE: myapp_production
178
+ DB_USERNAME:
179
+ DB_PASSWORD:
180
+ DB_CHARSET: utf8mb4
181
+ YAML
182
+
183
+ File.write(env_path, env_content)
184
+ puts " create #{env_path}"
185
+ end
186
+ end
187
+ end
@@ -0,0 +1,34 @@
1
+ # lib/hyraft/compiler/compiler.rb
2
+ require_relative 'renderer'
3
+
4
+ module Hyraft
5
+ module Compiler
6
+ class HyraftCompiler
7
+ def initialize(layout_file)
8
+ @layout_file = layout_file
9
+ @renderer = HyraftRenderer.new
10
+ end
11
+
12
+ # compile(view_file, data = {}) -> string
13
+ def compile(view_file, data = {})
14
+ layout_content = File.read(@layout_file)
15
+ parsed = parse_hyraft(view_file)
16
+ @renderer.render(layout_content, parsed, data)
17
+ end
18
+
19
+ private
20
+
21
+ def parse_hyraft(file)
22
+ content = File.read(file)
23
+ {
24
+ displayer: content[/\<displayer html\>(.*?)\<\/displayer\>/m, 1],
25
+ transmuter: content[/\<transmuter rb\>(.*?)\<\/transmuter\>/m, 1],
26
+ styles: content.scan(/<style\s+src="([^"]+)"\s*\/?>/).flatten,
27
+ manifestor: content[/\<manifestor js\>(.*?)\<\/manifestor\>/m, 1],
28
+ metadata: content[/\<metadata html\>(.*?)\<\/metadata\>/m, 1],
29
+ metas: content[/\<metas html\>(.*?)\<\/metas\>/m, 1]
30
+ }
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,181 @@
1
+ # lib/hyraft/compiler/html_purifier.rb
2
+ module Hyraft
3
+ module Compiler
4
+ module HtmlPurifier
5
+ # Purifies HTML content by escaping dangerous characters to prevent XSS attacks
6
+ # Usage: purify_html(user_input) - makes any string safe for HTML output
7
+ def purify_html(unsafe)
8
+ return '' unless unsafe
9
+ unsafe.to_s
10
+ .gsub(/&/, "&amp;")
11
+ .gsub(/</, "&lt;")
12
+ .gsub(/>/, "&gt;")
13
+ .gsub(/"/, "&quot;")
14
+ .gsub(/'/, "&#039;")
15
+ end
16
+
17
+ # Alias for purify_html for different use cases
18
+ def escape_html(unsafe)
19
+ purify_html(unsafe)
20
+ end
21
+
22
+ # Additional HTML safety methods
23
+ def purify_html_attribute(unsafe)
24
+ return '' unless unsafe
25
+ unsafe.to_s
26
+ .gsub(/&/, "&amp;")
27
+ .gsub(/</, "&lt;")
28
+ .gsub(/>/, "&gt;")
29
+ .gsub(/"/, "&quot;")
30
+ .gsub(/'/, "&#x27;")
31
+ .gsub(/\//, "&#x2F;")
32
+ end
33
+
34
+ def purify_javascript(unsafe)
35
+ return '' unless unsafe
36
+ unsafe.to_s
37
+ .gsub(/\\/, "\\\\")
38
+ .gsub(/'/, "\\'")
39
+ .gsub(/"/, "\\\"")
40
+ .gsub(/\n/, "\\n")
41
+ .gsub(/\r/, "\\r")
42
+ .gsub(/</, "\\u003C")
43
+ end
44
+
45
+ # Highlight text with search term while maintaining HTML safety
46
+ # @param text [String] The text to search within
47
+ # @param term [String] The search term to highlight
48
+ # @param highlight_tag [String] HTML tag to use for highlighting (default: 'mark')
49
+ # @param css_class [String] CSS class to add to highlight tag (optional)
50
+ # @return [String] Text with highlighted terms
51
+ def highlight_text(text, term, highlight_tag: 'mark', css_class: nil)
52
+ return text if term.empty? || text.empty?
53
+
54
+ # First purify the text to make it safe
55
+ safe_text = purify_html(text)
56
+ safe_term = purify_html(term)
57
+
58
+ # Escape the term for regex
59
+ escaped_term = Regexp.escape(safe_term)
60
+
61
+ # Build the opening tag with optional CSS class
62
+ opening_tag = if css_class
63
+ "<#{highlight_tag} class=\"#{purify_html_attribute(css_class)}\">"
64
+ else
65
+ "<#{highlight_tag}>"
66
+ end
67
+
68
+ closing_tag = "</#{highlight_tag}>"
69
+
70
+ # Highlight the term
71
+ safe_text.gsub(/(#{escaped_term})/i) do |match|
72
+ "#{opening_tag}#{match}#{closing_tag}"
73
+ end
74
+ end
75
+
76
+ # Alternative highlighting with span and default Bootstrap classes
77
+ # @param text [String] The text to search within
78
+ # @param term [String] The search term to highlight
79
+ # @param highlight_class [String] CSS class for highlighting (default: 'bg-warning text-dark px-1 rounded')
80
+ # @return [String] Text with highlighted terms
81
+ def highlight_with_class(text, term, highlight_class: 'bg-warning text-dark px-1 rounded')
82
+ highlight_text(text, term, highlight_tag: 'span', css_class: highlight_class)
83
+ end
84
+
85
+ # ========== HTML HELPER METHODS ==========
86
+
87
+ # Truncate text to specified length
88
+ # @param text [String] The text to truncate
89
+ # @param length [Integer] Maximum length before truncation (default: 100)
90
+ # @param suffix [String] Suffix to add when truncated (default: '...')
91
+ # @param preserve_words [Boolean] Whether to preserve whole words (default: false)
92
+ # @return [String] Truncated text
93
+ def truncate_text(text, length: 100, suffix: '...', preserve_words: false)
94
+ return '' unless text
95
+ text = text.to_s
96
+
97
+ if text.length <= length
98
+ return text
99
+ end
100
+
101
+ if preserve_words
102
+ # Try to break at a word boundary
103
+ truncated = text[0, length]
104
+ last_space = truncated.rindex(/\s/)
105
+
106
+ if last_space && last_space > length * 0.8 # Only break if we have a reasonable word boundary
107
+ truncated = text[0, last_space]
108
+ end
109
+
110
+ truncated + suffix
111
+ else
112
+ text[0, length] + suffix
113
+ end
114
+ end
115
+
116
+ # Create a safe HTML link
117
+ # @param text [String] The link text
118
+ # @param url [String] The link URL
119
+ # @param attrs [Hash] Additional HTML attributes (e.g., {class: 'btn', target: '_blank'})
120
+ # @return [String] Safe HTML <a> tag
121
+ def link_to(text, url, **attrs)
122
+ # Build attributes string
123
+ attr_string = attrs.map do |key, value|
124
+ "#{key}=\"#{purify_html_attribute(value)}\""
125
+ end.join(' ')
126
+
127
+ attr_string = " #{attr_string}" unless attr_string.empty?
128
+
129
+ "<a href=\"#{purify_html_attribute(url)}\"#{attr_string}>#{purify_html(text)}</a>"
130
+ end
131
+
132
+ # Format datetime object
133
+ # @param datetime [Time, DateTime, String] The datetime to format
134
+ # @param format [String] strftime format string (default: '%Y-%m-%d %H:%M')
135
+ # @param default [String] Default value if datetime is nil (default: '')
136
+ # @return [String] Formatted datetime
137
+ def format_datetime(datetime, format: '%Y-%m-%d %H:%M', default: '')
138
+ return default unless datetime
139
+
140
+ if datetime.is_a?(String)
141
+ # Try to parse the string
142
+ begin
143
+ datetime = Time.parse(datetime)
144
+ rescue
145
+ return default
146
+ end
147
+ end
148
+
149
+ if datetime.respond_to?(:strftime)
150
+ datetime.strftime(format)
151
+ else
152
+ default
153
+ end
154
+ end
155
+
156
+ # Format a number with commas (e.g., 1000 -> "1,000")
157
+ # @param number [Numeric, String] The number to format
158
+ # @param delimiter [String] Thousands delimiter (default: ',')
159
+ # @param separator [String] Decimal separator (default: '.')
160
+ # @return [String] Formatted number
161
+ def number_with_delimiter(number, delimiter: ',', separator: '.')
162
+ return '' unless number
163
+
164
+ parts = number.to_s.split('.')
165
+ parts[0] = parts[0].reverse.scan(/\d{1,3}/).join(delimiter).reverse
166
+
167
+ parts.join(separator)
168
+ end
169
+
170
+ # Pluralize a word based on count
171
+ # @param count [Integer] The count
172
+ # @param singular [String] Singular form of the word
173
+ # @param plural [String] Plural form of the word (optional, will add 's' by default)
174
+ # @return [String] Pluralized phrase
175
+ def pluralize(count, singular, plural = nil)
176
+ plural ||= singular + 's'
177
+ count == 1 ? "1 #{singular}" : "#{count} #{plural}"
178
+ end
179
+ end
180
+ end
181
+ end