poml 0.0.1 → 0.0.2

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.
data/lib/poml/parser.rb CHANGED
@@ -32,6 +32,9 @@ module Poml
32
32
  # Handle escape characters
33
33
  content = unescape_poml(content)
34
34
 
35
+ # Pre-process to handle JSON in attributes (convert \" to " inside attribute values)
36
+ content = preprocess_json_attributes(content)
37
+
35
38
  # Remove XML comments but preserve surrounding whitespace
36
39
  content = content.gsub(/(\s*)<!--.*?-->(\s*)/m) do |match|
37
40
  before_space = $1
@@ -44,6 +47,9 @@ module Poml
44
47
  end
45
48
  end
46
49
 
50
+ # Convert HTML-style void elements to XML self-closing format
51
+ content = preprocess_void_elements(content)
52
+
47
53
  # Apply template substitutions
48
54
  content = @template_engine.substitute(content)
49
55
 
@@ -86,17 +92,15 @@ module Poml
86
92
  text_content = child.to_s
87
93
  next if text_content.strip.empty? # Only skip if completely empty when stripped
88
94
 
89
- # Check if the content (after removing newlines) ends with a space
90
- content_no_newlines = text_content.gsub(/\n/, ' ')
91
- preserves_trailing_space = content_no_newlines.rstrip != content_no_newlines
92
-
93
- # Normalize the text: strip leading/trailing whitespace
94
- normalized = text_content.strip
95
+ # For inline content, preserve spaces but normalize newlines
96
+ # Convert newlines to single spaces to avoid formatting issues
97
+ normalized = text_content.gsub(/\s*\n\s*/, ' ')
95
98
 
96
- # Add back trailing space if it was significant (i.e., there was a space before newlines)
97
- normalized += ' ' if preserves_trailing_space
99
+ # Trim only if the content is just whitespace, otherwise preserve leading/trailing spaces
100
+ if normalized.strip.empty?
101
+ next
102
+ end
98
103
 
99
- next if normalized.empty?
100
104
  elements << Element.new(tag_name: :text, content: normalized)
101
105
  when REXML::Element
102
106
  # Convert REXML attributes to string hash
@@ -105,12 +109,27 @@ module Poml
105
109
  attrs[name.downcase] = value.to_s
106
110
  end
107
111
 
108
- elements << Element.new(
109
- tag_name: child.name.downcase.to_sym,
110
- attributes: attrs,
111
- content: extract_text_content(child),
112
- children: parse_element(child)
113
- )
112
+ # Check for conditional and loop attributes before creating element
113
+ if_condition = attrs['if']
114
+ for_attribute = attrs['for']
115
+
116
+ # Handle conditional rendering
117
+ if if_condition && !evaluate_if_condition(if_condition)
118
+ next # Skip this element
119
+ end
120
+
121
+ # Handle for loop rendering
122
+ if for_attribute
123
+ loop_elements = render_for_loop(child, attrs, for_attribute)
124
+ elements.concat(loop_elements)
125
+ else
126
+ elements << Element.new(
127
+ tag_name: child.name.downcase.to_sym,
128
+ attributes: attrs,
129
+ content: extract_text_content(child),
130
+ children: parse_element(child)
131
+ )
132
+ end
114
133
  end
115
134
  end
116
135
 
@@ -149,5 +168,99 @@ module Poml
149
168
  end
150
169
  end
151
170
  end
171
+
172
+ def evaluate_if_condition(condition)
173
+ value = @template_engine.evaluate_attribute_expression(condition)
174
+ !!value
175
+ end
176
+
177
+ def render_for_loop(xml_element, attrs, for_attribute)
178
+ # Parse for attribute like "i in [1,2,3]" or "item in items"
179
+ if for_attribute =~ /^(\w+)\s+in\s+(.+)$/
180
+ loop_var = $1
181
+ list_expr = $2.strip
182
+
183
+ # Evaluate the list expression
184
+ list = @template_engine.evaluate_attribute_expression(list_expr)
185
+ return [] unless list.is_a?(Array)
186
+
187
+ # Create elements for each item in the list
188
+ elements = []
189
+ list.each_with_index do |item, index|
190
+ # Create loop context
191
+ old_loop_var = @context.variables[loop_var]
192
+ old_loop_context = @context.variables['loop']
193
+
194
+ @context.variables[loop_var] = item
195
+ @context.variables['loop'] = {
196
+ 'index' => index,
197
+ 'length' => list.length,
198
+ 'first' => index == 0,
199
+ 'last' => index == list.length - 1
200
+ }
201
+
202
+ # Remove for attribute and process element normally
203
+ loop_attrs = attrs.dup
204
+ loop_attrs.delete('for')
205
+
206
+ element = Element.new(
207
+ tag_name: xml_element.name.downcase.to_sym,
208
+ attributes: loop_attrs,
209
+ content: extract_text_content(xml_element),
210
+ children: parse_element(xml_element)
211
+ )
212
+
213
+ elements << element
214
+
215
+ # Restore previous context
216
+ if old_loop_var
217
+ @context.variables[loop_var] = old_loop_var
218
+ else
219
+ @context.variables.delete(loop_var)
220
+ end
221
+
222
+ if old_loop_context
223
+ @context.variables['loop'] = old_loop_context
224
+ else
225
+ @context.variables.delete('loop')
226
+ end
227
+ end
228
+
229
+ elements
230
+ else
231
+ # Invalid for syntax, return empty
232
+ []
233
+ end
234
+ end
235
+
236
+ def preprocess_void_elements(content)
237
+ # List of HTML void elements that should be self-closing in XML
238
+ void_elements = %w[br hr img input area base col embed link meta param source track wbr]
239
+
240
+ # Convert <element> to <element/> for void elements, but only if not already self-closing
241
+ void_elements.each do |element|
242
+ # Match opening tag that's not already self-closing
243
+ # Use a more specific pattern to avoid matching already self-closing tags
244
+ pattern = /<(#{element})(\s+[^>\/]*?)?(?<!\/)>/i
245
+ content = content.gsub(pattern) do |match|
246
+ element_name = $1
247
+ attributes = $2 || ''
248
+ "<#{element_name}#{attributes}/>"
249
+ end
250
+ end
251
+
252
+ content
253
+ end
254
+
255
+ def preprocess_json_attributes(content)
256
+ # Convert problematic characters in attribute values to make them valid XML
257
+ content = content.gsub(/\\"/, '&quot;') # Handle JSON quotes
258
+
259
+ # Handle comparison operators in attribute values
260
+ content = content.gsub(/(\w+\s*=\s*"[^"]*?)(<)([^"]*?")/m, '\1&lt;\3')
261
+ content = content.gsub(/(\w+\s*=\s*"[^"]*?)(>)([^"]*?")/m, '\1&gt;\3')
262
+
263
+ content
264
+ end
152
265
  end
153
266
  end
data/lib/poml/renderer.rb CHANGED
@@ -42,19 +42,38 @@ module Poml
42
42
  end
43
43
 
44
44
  def render_dict(elements)
45
+ # Render content first to allow meta components to modify context
46
+ content = render_raw(elements)
47
+
48
+ # Gather metadata after rendering
49
+ metadata = {
50
+ 'chat' => @context.chat,
51
+ 'stylesheet' => @context.stylesheet,
52
+ 'variables' => @context.variables
53
+ }
54
+
55
+ # Include custom metadata (title, description, etc.)
56
+ metadata.merge!(@context.custom_metadata) if @context.custom_metadata && !@context.custom_metadata.empty?
57
+
58
+ # Include additional metadata if present
59
+ metadata['response_schema'] = @context.response_schema if @context.response_schema
60
+ metadata['tools'] = @context.tools if @context.tools && !@context.tools.empty?
61
+ metadata['runtime_parameters'] = @context.runtime_parameters if @context.runtime_parameters && !@context.runtime_parameters.empty?
62
+
45
63
  {
46
- 'content' => render_raw(elements),
47
- 'metadata' => {
48
- 'chat' => @context.chat,
49
- 'stylesheet' => @context.stylesheet,
50
- 'variables' => @context.variables
51
- }
64
+ 'content' => content,
65
+ 'metadata' => metadata
52
66
  }
53
67
  end
54
68
 
55
69
  def render_openai_chat(elements)
70
+ # First render to collect structured messages
56
71
  content = render_raw(elements)
57
- if @context.chat
72
+
73
+ # Use structured messages if available
74
+ if @context.respond_to?(:chat_messages) && !@context.chat_messages.empty?
75
+ @context.chat_messages
76
+ elsif @context.chat
58
77
  parse_chat_messages(content)
59
78
  else
60
79
  [{ 'role' => 'user', 'content' => content }]
@@ -1,5 +1,5 @@
1
1
  module Poml
2
- # Template engine for handling {{variable}} substitutions
2
+ # Template engine for handling {{variable}} substitutions and control structures
3
3
  class TemplateEngine
4
4
  def initialize(context)
5
5
  @context = context
@@ -15,6 +15,37 @@ module Poml
15
15
  end
16
16
  end
17
17
 
18
+ def evaluate_attribute_expression(expression)
19
+ # Handle attribute expressions that might return non-string values
20
+ if expression =~ /^(\w+(?:\.\w+)*)\s*\+\s*(\d+)$/
21
+ variable_path = $1
22
+ increment = $2.to_i
23
+
24
+ value = get_nested_variable(variable_path)
25
+ if value.is_a?(Numeric)
26
+ value + increment
27
+ else
28
+ expression
29
+ end
30
+ elsif expression =~ /^(\w+(?:\.\w+)*)$/
31
+ # Simple variable or dot notation lookup
32
+ variable_path = $1
33
+ get_nested_variable(variable_path)
34
+ elsif expression =~ /^(true|false)$/i
35
+ $1.downcase == 'true'
36
+ elsif expression =~ /^-?\d+$/
37
+ $1.to_i
38
+ elsif expression =~ /^-?\d*\.\d+$/
39
+ $1.to_f
40
+ elsif @context.variables.key?(expression)
41
+ # Direct variable lookup (backward compatibility)
42
+ @context.variables[expression]
43
+ else
44
+ # Try to evaluate as a more complex expression
45
+ evaluate_complex_expression(expression)
46
+ end
47
+ end
48
+
18
49
  private
19
50
 
20
51
  def evaluate_expression(expression)
@@ -40,15 +71,81 @@ module Poml
40
71
  # Direct variable lookup (backward compatibility)
41
72
  @context.variables[expression].to_s
42
73
  else
43
- # Return original expression if not found
44
- "{{#{expression}}}"
74
+ # Try to evaluate as a more complex expression
75
+ result = evaluate_complex_expression(expression)
76
+ result ? result.to_s : "{{#{expression}}}"
77
+ end
78
+ end
79
+
80
+ def evaluate_complex_expression(expression)
81
+ # Handle more complex expressions like array literals, object access, etc.
82
+
83
+ # Try to parse as JSON first (for arrays and objects)
84
+ begin
85
+ require 'json'
86
+ return JSON.parse(expression)
87
+ rescue JSON::ParserError
88
+ # Not valid JSON, continue with other parsing
89
+ end
90
+
91
+ # Array literals like ['apple', 'banana', 'cherry']
92
+ if expression =~ /^\[(.+)\]$/
93
+ array_content = $1
94
+ # Simple parsing for string arrays
95
+ if array_content.match(/^'[^']*'(?:\s*,\s*'[^']*')*$/)
96
+ return array_content.split(',').map { |item| item.strip.gsub(/^'|'$/, '') }
97
+ end
98
+ end
99
+
100
+ # Object literals like { name: 'John', age: 30 }
101
+ if expression =~ /^\{(.+)\}$/
102
+ # This would need a proper expression parser for full support
103
+ # For now, return the expression as-is
104
+ return expression
105
+ end
106
+
107
+ # Ternary operator like condition ? valueIfTrue : valueIfFalse
108
+ if expression =~ /^(.+)\s*\?\s*(.+)\s*:\s*(.+)$/
109
+ condition = $1.strip
110
+ true_value = $2.strip
111
+ false_value = $3.strip
112
+
113
+ condition_result = evaluate_condition(condition)
114
+ if condition_result
115
+ evaluate_expression(true_value)
116
+ else
117
+ evaluate_expression(false_value)
118
+ end
119
+ end
120
+
121
+ nil
122
+ end
123
+
124
+ def evaluate_condition(condition)
125
+ # Simple condition evaluation
126
+ case condition
127
+ when 'true'
128
+ true
129
+ when 'false'
130
+ false
131
+ when /^!(.+)$/
132
+ !evaluate_condition($1.strip)
133
+ else
134
+ value = get_nested_variable(condition)
135
+ !!value
45
136
  end
46
137
  end
47
138
 
48
139
  def get_nested_variable(path)
49
140
  # Handle dot notation like "loop.index"
50
141
  parts = path.split('.')
51
- value = @context.variables
142
+
143
+ # Handle both Context objects and raw variable hashes
144
+ value = if @context.respond_to?(:variables)
145
+ @context.variables
146
+ else
147
+ @context
148
+ end
52
149
 
53
150
  parts.each do |part|
54
151
  if value.is_a?(Hash) && value.key?(part)
data/lib/poml/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Poml
4
- VERSION = "0.0.1"
4
+ VERSION = "0.0.2"
5
5
  end
data/lib/poml.rb CHANGED
@@ -2,10 +2,10 @@
2
2
 
3
3
  require_relative "poml/version"
4
4
  require_relative 'poml/context'
5
+ require_relative 'poml/template_engine'
5
6
  require_relative 'poml/parser'
6
7
  require_relative 'poml/renderer'
7
8
  require_relative 'poml/components'
8
- require_relative 'poml/template_engine'
9
9
 
10
10
  module Poml
11
11
  class Error < StandardError; end
@@ -50,4 +50,70 @@ module Poml
50
50
 
51
51
  result
52
52
  end
53
+
54
+ def self.parse(content, context: nil)
55
+ context ||= Context.new
56
+ parser = Parser.new(context)
57
+ parser.parse(content)
58
+ end
59
+
60
+ def self.render(content, format: 'text', context: nil, **options)
61
+ context ||= Context.new(**options)
62
+ elements = parse(content, context: context)
63
+ renderer = Renderer.new(context)
64
+ renderer.render(elements, format)
65
+ end
66
+
67
+ # Convenience method for quick text rendering
68
+ def self.to_text(content, **options)
69
+ render(content, format: 'raw', **options)
70
+ end
71
+
72
+ # Convenience method for chat format
73
+ def self.to_chat(content, **options)
74
+ render(content, format: 'openai_chat', **options)
75
+ end
76
+
77
+ # Convenience method for dict format
78
+ def self.to_dict(content, **options)
79
+ render(content, format: 'dict', **options)
80
+ end
81
+
82
+ # Legacy method for backward compatibility
83
+ def self.process(markup:, format: 'dict', **options)
84
+ # Handle file paths
85
+ content = if File.exist?(markup)
86
+ File.read(markup)
87
+ else
88
+ markup
89
+ end
90
+
91
+ # Extract output file option
92
+ output_file = options.delete(:output_file)
93
+
94
+ # Extract context from various parameter formats
95
+ if options.key?(:context)
96
+ # If context is provided explicitly, use it as variables
97
+ context_options = {}
98
+ context_options[:variables] = options.delete(:context)
99
+ else
100
+ # Extract known context options and ignore unknown ones
101
+ context_options = {}
102
+ context_options[:variables] = options.delete(:variables) || options.reject { |k, v| [:stylesheet, :chat, :syntax].include?(k) }
103
+ end
104
+
105
+ context_options[:stylesheet] = options.delete(:stylesheet) if options.key?(:stylesheet)
106
+ context_options[:chat] = options.delete(:chat) if options.key?(:chat)
107
+ context_options[:syntax] = options.delete(:syntax) if options.key?(:syntax)
108
+
109
+ result = render(content, format: format, **context_options)
110
+
111
+ # Write to file if output_file is specified
112
+ if output_file
113
+ File.write(output_file, result)
114
+ '' # Return empty string when writing to file
115
+ else
116
+ result
117
+ end
118
+ end
53
119
  end
@@ -6,17 +6,25 @@ A Ruby implementation of the POML (Prompt Oriented Markup Language) interpreter.
6
6
 
7
7
  This is a **Ruby port** of the original [POML library](https://github.com/microsoft/poml) developed by Microsoft, which was originally implemented in JavaScript/TypeScript and Python. This Ruby gem is designed to be **fully compatible** with the original POML specification and will **closely follow** the development of the original library to maintain feature parity.
8
8
 
9
+ ## Demo Video
10
+
11
+ [![The 5-minute guide to POML](https://i3.ytimg.com/vi/b9WDcFsKixo/maxresdefault.jpg)](https://youtu.be/b9WDcFsKixo)
12
+
9
13
  ### Original Library Resources
10
14
 
11
15
  For comprehensive documentation, tutorials, and examples, please refer to the **original POML library documentation**:
12
16
 
13
17
  - 📚 **Main Repository**: <https://github.com/microsoft/poml>
14
- - 📖 **Documentation**: Complete language reference and guides
18
+ - 📖 **Documentation**: [Complete language reference and guides](https://microsoft.github.io/poml/latest/)
15
19
  - 💡 **Examples**: Extensive collection of POML examples
16
20
  - 🎯 **Use Cases**: Real-world applications and patterns
17
21
 
18
22
  The original documentation is an excellent resource for learning POML concepts, syntax, and best practices that apply to this Ruby implementation as well.
19
23
 
24
+ ## Implementation status
25
+
26
+ Please refer to [ROADMAP.md](https://github.com/GhennadiiMir/poml/blob/main/ROADMAP.md) for understanding which features are already implemented.
27
+
20
28
  ## Installation
21
29
 
22
30
  ```bash
metadata CHANGED
@@ -1,13 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: poml
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.0.1
4
+ version: 0.0.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ghennadii Mirosnicenco
8
8
  bindir: bin
9
9
  cert_chain: []
10
- date: 2025-08-17 00:00:00.000000000 Z
10
+ date: 2025-08-18 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: rexml
@@ -50,7 +50,6 @@ extensions: []
50
50
  extra_rdoc_files: []
51
51
  files:
52
52
  - LICENSE.txt
53
- - README.md
54
53
  - TUTORIAL.md
55
54
  - bin/poml
56
55
  - examples/101_explain_character.poml
@@ -64,7 +63,6 @@ files:
64
63
  - examples/202_arc_agi.poml
65
64
  - examples/301_generate_poml.poml
66
65
  - examples/README.md
67
- - examples/_generate_expects.py
68
66
  - examples/assets/101_jerry_mouse.jpg
69
67
  - examples/assets/101_tom_and_jerry.docx
70
68
  - examples/assets/101_tom_cat.jpg
@@ -104,11 +102,16 @@ files:
104
102
  - lib/poml/components/content.rb
105
103
  - lib/poml/components/data.rb
106
104
  - lib/poml/components/examples.rb
105
+ - lib/poml/components/formatting.rb
107
106
  - lib/poml/components/instructions.rb
108
107
  - lib/poml/components/layout.rb
109
108
  - lib/poml/components/lists.rb
109
+ - lib/poml/components/media.rb
110
+ - lib/poml/components/meta.rb
110
111
  - lib/poml/components/styling.rb
112
+ - lib/poml/components/template.rb
111
113
  - lib/poml/components/text.rb
114
+ - lib/poml/components/utilities.rb
112
115
  - lib/poml/components/workflow.rb
113
116
  - lib/poml/components_new.rb
114
117
  - lib/poml/components_old.rb
@@ -119,6 +122,7 @@ files:
119
122
  - lib/poml/version.rb
120
123
  - media/logo-16-purple.png
121
124
  - media/logo-64-white.png
125
+ - readme.md
122
126
  homepage: https://github.com/GhennadiiMir/poml
123
127
  licenses:
124
128
  - MIT
@@ -1,35 +0,0 @@
1
- import os
2
- import poml
3
- import io
4
- import sys
5
- from contextlib import redirect_stdout
6
-
7
-
8
- def process_example(example_content, output_file):
9
- """
10
- Process the example content and return the expected output.
11
- """
12
- # Capture stdout
13
- poml.poml(example_content, format="raw", output_file=output_file, extra_args=["--prettyPrint", "true"])
14
-
15
-
16
- def generate_expectations():
17
- """
18
- Generate the expected output files for the examples.
19
- """
20
- examples_dir = os.path.abspath(os.path.dirname(__file__))
21
- expect_dir = os.path.join(examples_dir, "expects")
22
- print("Generating expectations in:", expect_dir)
23
-
24
- for example_file in sorted(os.listdir(examples_dir)):
25
- if example_file.endswith(".poml"):
26
- print(f"Processing example: {example_file}")
27
- # Generate the expected output
28
- process_example(
29
- os.path.join(examples_dir, example_file),
30
- os.path.join(expect_dir, example_file.replace(".poml", ".txt")),
31
- )
32
-
33
-
34
- if __name__ == "__main__":
35
- generate_expectations()