poml 0.0.1

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 (72) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE.txt +21 -0
  3. data/README.md +239 -0
  4. data/TUTORIAL.md +987 -0
  5. data/bin/poml +80 -0
  6. data/examples/101_explain_character.poml +30 -0
  7. data/examples/102_render_xml.poml +40 -0
  8. data/examples/103_word_todos.poml +27 -0
  9. data/examples/104_financial_analysis.poml +33 -0
  10. data/examples/105_write_blog_post.poml +48 -0
  11. data/examples/106_research.poml +36 -0
  12. data/examples/107_read_report_pdf.poml +4 -0
  13. data/examples/201_orders_qa.poml +50 -0
  14. data/examples/202_arc_agi.poml +36 -0
  15. data/examples/301_generate_poml.poml +46 -0
  16. data/examples/README.md +50 -0
  17. data/examples/_generate_expects.py +35 -0
  18. data/examples/assets/101_jerry_mouse.jpg +0 -0
  19. data/examples/assets/101_tom_and_jerry.docx +0 -0
  20. data/examples/assets/101_tom_cat.jpg +0 -0
  21. data/examples/assets/101_tom_introduction.txt +9 -0
  22. data/examples/assets/103_prompt_wizard.docx +0 -0
  23. data/examples/assets/104_chart_normalized_price.png +0 -0
  24. data/examples/assets/104_chart_price.png +0 -0
  25. data/examples/assets/104_mag7.xlsx +0 -0
  26. data/examples/assets/107_usenix_paper.pdf +0 -0
  27. data/examples/assets/201_order_instructions.json +7 -0
  28. data/examples/assets/201_orderlines.csv +2 -0
  29. data/examples/assets/201_orders.csv +3 -0
  30. data/examples/assets/202_arc_agi_data.json +1 -0
  31. data/examples/expects/101_explain_character.txt +117 -0
  32. data/examples/expects/102_render_xml.txt +28 -0
  33. data/examples/expects/103_word_todos.txt +121 -0
  34. data/examples/expects/104_financial_analysis.txt +86 -0
  35. data/examples/expects/105_write_blog_post.txt +41 -0
  36. data/examples/expects/106_research.txt +29 -0
  37. data/examples/expects/107_read_report_pdf.txt +151 -0
  38. data/examples/expects/201_orders_qa.txt +44 -0
  39. data/examples/expects/202_arc_agi.txt +64 -0
  40. data/examples/expects/301_generate_poml.txt +153 -0
  41. data/examples/ruby_expects/101_explain_character.txt +17 -0
  42. data/examples/ruby_expects/102_render_xml.txt +28 -0
  43. data/examples/ruby_expects/103_word_todos.txt +14 -0
  44. data/examples/ruby_expects/104_financial_analysis.txt +0 -0
  45. data/examples/ruby_expects/105_write_blog_post.txt +57 -0
  46. data/examples/ruby_expects/106_research.txt +5 -0
  47. data/examples/ruby_expects/107_read_report_pdf.txt +403 -0
  48. data/examples/ruby_expects/201_orders_qa.txt +41 -0
  49. data/examples/ruby_expects/202_arc_agi.txt +17 -0
  50. data/examples/ruby_expects/301_generate_poml.txt +17 -0
  51. data/lib/poml/components/base.rb +132 -0
  52. data/lib/poml/components/content.rb +156 -0
  53. data/lib/poml/components/data.rb +346 -0
  54. data/lib/poml/components/examples.rb +55 -0
  55. data/lib/poml/components/instructions.rb +93 -0
  56. data/lib/poml/components/layout.rb +50 -0
  57. data/lib/poml/components/lists.rb +82 -0
  58. data/lib/poml/components/styling.rb +36 -0
  59. data/lib/poml/components/text.rb +8 -0
  60. data/lib/poml/components/workflow.rb +63 -0
  61. data/lib/poml/components.rb +47 -0
  62. data/lib/poml/components_new.rb +297 -0
  63. data/lib/poml/components_old.rb +1096 -0
  64. data/lib/poml/context.rb +53 -0
  65. data/lib/poml/parser.rb +153 -0
  66. data/lib/poml/renderer.rb +147 -0
  67. data/lib/poml/template_engine.rb +66 -0
  68. data/lib/poml/version.rb +5 -0
  69. data/lib/poml.rb +53 -0
  70. data/media/logo-16-purple.png +0 -0
  71. data/media/logo-64-white.png +0 -0
  72. metadata +149 -0
@@ -0,0 +1,156 @@
1
+ module Poml
2
+ # Document component (reads and includes external files)
3
+ class DocumentComponent < Component
4
+ def initialize(element, context)
5
+ super
6
+ @src = element.attributes['src']
7
+ @selected_pages = element.attributes['selectedpages'] || element.attributes['selectedPages']
8
+ @syntax = element.attributes['syntax'] || 'text'
9
+ end
10
+
11
+ def render(context = nil)
12
+ return "[Document: no src specified]" unless @src
13
+
14
+ begin
15
+ # Resolve file path - try relative to current working directory first
16
+ file_path = @src
17
+ unless File.exist?(file_path)
18
+ # Try relative to examples directory
19
+ examples_dir = File.expand_path('examples')
20
+ file_path = File.join(examples_dir, @src)
21
+ end
22
+
23
+ unless File.exist?(file_path)
24
+ # Try relative to project root examples directory
25
+ project_root = File.expand_path('..', File.dirname(__dir__))
26
+ examples_dir = File.join(project_root, 'examples')
27
+ file_path = File.join(examples_dir, @src)
28
+ end
29
+
30
+ # Check if file exists
31
+ unless File.exist?(file_path)
32
+ return "[Document: #{@src} (not found)]"
33
+ end
34
+
35
+ # Check file type and extract content
36
+ if file_path.downcase.end_with?('.pdf')
37
+ read_pdf_content(file_path)
38
+ elsif file_path.downcase.end_with?('.docx')
39
+ read_docx_content(file_path)
40
+ else
41
+ File.read(file_path)
42
+ end
43
+ rescue => e
44
+ "[Document: #{@src} (error reading: #{e.message})]"
45
+ end
46
+ end
47
+
48
+ private
49
+
50
+ def read_pdf_content(file_path)
51
+ if @selected_pages
52
+ # Parse Python-style slice notation
53
+ start_page, end_page = parse_python_style_slice(@selected_pages, get_pdf_page_count(file_path))
54
+
55
+ # Convert 0-indexed to 1-indexed for pdftotext (-f and -l are 1-indexed)
56
+ start_page_1indexed = start_page + 1
57
+ # For Python slice "1:3" -> start=1, end=3 (0-indexed, end exclusive)
58
+ # This means we want pages 1,2 (0-indexed) = pages 2,3 (1-indexed)
59
+ # So pdftotext should use -f 2 -l 3
60
+ last_page_1indexed = start_page_1indexed + (end_page - start_page) - 1
61
+
62
+ if end_page > start_page + 1
63
+ # Extract range of pages
64
+ command = "pdftotext -f #{start_page_1indexed} -l #{last_page_1indexed} \"#{file_path}\" -"
65
+ result = `#{command}`
66
+ else
67
+ # Single page
68
+ command = "pdftotext -f #{start_page_1indexed} -l #{start_page_1indexed} \"#{file_path}\" -"
69
+ result = `#{command}`
70
+ end
71
+ else
72
+ # Extract all pages
73
+ result = `pdftotext "#{file_path}" -`
74
+ end
75
+
76
+ if $?.success?
77
+ result
78
+ else
79
+ "[Document: #{@src} (error extracting PDF)]"
80
+ end
81
+ end
82
+
83
+ def parse_python_style_slice(slice, total_length)
84
+ # Handle different slice formats: "1:3", ":3", "3:", "3", ":"
85
+ if slice == ':'
86
+ [0, total_length]
87
+ elsif slice.end_with?(':')
88
+ [slice[0..-2].to_i, total_length]
89
+ elsif slice.start_with?(':')
90
+ [0, slice[1..-1].to_i]
91
+ elsif slice.include?(':')
92
+ parts = slice.split(':')
93
+ [parts[0].to_i, parts[1].to_i]
94
+ else
95
+ index = slice.to_i
96
+ [index, index + 1]
97
+ end
98
+ end
99
+
100
+ def get_pdf_page_count(file_path)
101
+ # Get page count using pdfinfo (if available) or default to large number
102
+ result = `pdfinfo "#{file_path}" 2>/dev/null | grep "Pages:" | awk '{print $2}'`
103
+ if $?.success? && !result.strip.empty?
104
+ result.strip.to_i
105
+ else
106
+ # Fallback: try to extract and count pages
107
+ 100 # Default fallback
108
+ end
109
+ end
110
+
111
+ def read_docx_content(file_path)
112
+ # Try to extract text from .docx file using antiword or textutil (macOS)
113
+ # First try textutil (available on macOS)
114
+ result = `textutil -convert txt -stdout "#{file_path}" 2>/dev/null`
115
+ if $?.success? && !result.strip.empty?
116
+ return result
117
+ end
118
+
119
+ # Try pandoc if available
120
+ result = `pandoc "#{file_path}" -t plain 2>/dev/null`
121
+ if $?.success? && !result.strip.empty?
122
+ return result
123
+ end
124
+
125
+ # Try unzip to extract document.xml and parse it
126
+ begin
127
+ require 'zip'
128
+ content = ""
129
+ Zip::File.open(file_path) do |zip_file|
130
+ entry = zip_file.find_entry("word/document.xml")
131
+ if entry
132
+ xml_content = entry.get_input_stream.read
133
+ # Simple XML text extraction (not perfect but better than binary)
134
+ content = xml_content.gsub(/<[^>]*>/, ' ').gsub(/\s+/, ' ').strip
135
+ end
136
+ end
137
+ return content unless content.empty?
138
+ rescue => e
139
+ # Zip gem not available or error occurred
140
+ end
141
+
142
+ # Fallback: indicate that document could not be processed
143
+ "[Document: #{File.basename(file_path)} (Word document text extraction not available)]"
144
+ end
145
+ end
146
+
147
+ # Paragraph component for basic text content
148
+ class ParagraphComponent < Component
149
+ def render
150
+ apply_stylesheet
151
+
152
+ content = @element.content.empty? ? render_children : @element.content
153
+ "#{content}\n\n"
154
+ end
155
+ end
156
+ end
@@ -0,0 +1,346 @@
1
+ module Poml
2
+ # Table component for displaying tabular data
3
+ class TableComponent < Component
4
+ require 'csv'
5
+ require 'json'
6
+
7
+ def render
8
+ apply_stylesheet
9
+
10
+ src = get_attribute('src')
11
+ records_attr = get_attribute('records')
12
+ columns_attr = get_attribute('columns')
13
+ parser = get_attribute('parser', 'auto')
14
+ syntax = get_attribute('syntax')
15
+ selected_columns = get_attribute('selectedColumns')
16
+ selected_records = get_attribute('selectedRecords')
17
+ max_records = get_attribute('maxRecords')
18
+ max_columns = get_attribute('maxColumns')
19
+
20
+ # Load data from source or use provided records
21
+ data = if src
22
+ load_table_data(src, parser)
23
+ elsif records_attr
24
+ parse_records_attribute(records_attr)
25
+ else
26
+ { records: [], columns: [] }
27
+ end
28
+
29
+ # Apply column and record selection
30
+ data = apply_selection(data, selected_columns, selected_records, max_records, max_columns)
31
+
32
+ # Check syntax preference
33
+ if syntax == 'tsv' || syntax == 'csv'
34
+ render_table_raw(data, syntax)
35
+ elsif xml_mode?
36
+ render_table_xml(data)
37
+ else
38
+ render_table_markdown(data)
39
+ end
40
+ end
41
+
42
+ private
43
+
44
+ def load_table_data(src, parser)
45
+ # Resolve relative paths
46
+ file_path = if src.start_with?('/')
47
+ src
48
+ else
49
+ base_path = if @context.source_path
50
+ File.dirname(@context.source_path)
51
+ else
52
+ Dir.pwd
53
+ end
54
+ File.join(base_path, src)
55
+ end
56
+
57
+ unless File.exist?(file_path)
58
+ return { records: [], columns: [] }
59
+ end
60
+
61
+ # Determine parser from file extension if auto
62
+ if parser == 'auto'
63
+ ext = File.extname(file_path).downcase
64
+ parser = case ext
65
+ when '.csv' then 'csv'
66
+ when '.tsv' then 'tsv'
67
+ when '.json' then 'json'
68
+ when '.jsonl' then 'jsonl'
69
+ else 'csv'
70
+ end
71
+ end
72
+
73
+ case parser
74
+ when 'csv'
75
+ parse_csv_file(file_path)
76
+ when 'tsv'
77
+ parse_tsv_file(file_path)
78
+ when 'json'
79
+ parse_json_file(file_path)
80
+ when 'jsonl'
81
+ parse_jsonl_file(file_path)
82
+ else
83
+ { records: [], columns: [] }
84
+ end
85
+ rescue => e
86
+ { records: [], columns: [] }
87
+ end
88
+
89
+ def parse_csv_file(file_path)
90
+ data = CSV.read(file_path, headers: true)
91
+ columns = data.headers.map { |header| { field: header, header: header } }
92
+ records = data.map(&:to_h)
93
+ { records: records, columns: columns }
94
+ end
95
+
96
+ def parse_tsv_file(file_path)
97
+ data = CSV.read(file_path, headers: true, col_sep: "\t")
98
+ columns = data.headers.map { |header| { field: header, header: header } }
99
+ records = data.map(&:to_h)
100
+ { records: records, columns: columns }
101
+ end
102
+
103
+ def parse_json_file(file_path)
104
+ content = File.read(file_path)
105
+ records = JSON.parse(content)
106
+
107
+ # Extract columns from first record if it's an array of objects
108
+ columns = if records.is_a?(Array) && !records.empty? && records.first.is_a?(Hash)
109
+ records.first.keys.map { |key| { field: key, header: key } }
110
+ else
111
+ []
112
+ end
113
+
114
+ { records: records.is_a?(Array) ? records : [records], columns: columns }
115
+ end
116
+
117
+ def parse_jsonl_file(file_path)
118
+ records = []
119
+ File.readlines(file_path).each do |line|
120
+ records << JSON.parse(line.strip) unless line.strip.empty?
121
+ end
122
+
123
+ # Extract columns from first record
124
+ columns = if !records.empty? && records.first.is_a?(Hash)
125
+ records.first.keys.map { |key| { field: key, header: key } }
126
+ else
127
+ []
128
+ end
129
+
130
+ { records: records, columns: columns }
131
+ end
132
+
133
+ def parse_records_attribute(records_attr)
134
+ # Handle string records (JSON) or already parsed arrays
135
+ records = if records_attr.is_a?(String)
136
+ JSON.parse(records_attr)
137
+ else
138
+ records_attr
139
+ end
140
+
141
+ columns = if records.is_a?(Array) && !records.empty? && records.first.is_a?(Hash)
142
+ records.first.keys.map { |key| { field: key, header: key } }
143
+ else
144
+ []
145
+ end
146
+
147
+ { records: records.is_a?(Array) ? records : [records], columns: columns }
148
+ end
149
+
150
+ def apply_selection(data, selected_columns, selected_records, max_records, max_columns)
151
+ records = data[:records]
152
+ columns = data[:columns]
153
+
154
+ # Apply column selection
155
+ if selected_columns && columns
156
+ if selected_columns.is_a?(Array)
157
+ # Array of column names
158
+ new_columns = selected_columns.map do |col_name|
159
+ columns.find { |col| col[:field] == col_name } || { field: col_name, header: col_name }
160
+ end
161
+ columns = new_columns
162
+ records = records.map do |record|
163
+ selected_columns.each_with_object({}) { |col, new_record| new_record[col] = record[col] }
164
+ end
165
+ elsif selected_columns.is_a?(String) && selected_columns.include?(':')
166
+ # Python-style slice
167
+ start_idx, end_idx = parse_slice(selected_columns, columns.length)
168
+ columns = columns[start_idx...end_idx]
169
+ column_fields = columns.map { |col| col[:field] }
170
+ records = records.map do |record|
171
+ column_fields.each_with_object({}) { |field, new_record| new_record[field] = record[field] }
172
+ end
173
+ end
174
+ end
175
+
176
+ # Apply record selection
177
+ if selected_records
178
+ if selected_records.is_a?(Array)
179
+ records = selected_records.map { |idx| records[idx] }.compact
180
+ elsif selected_records.is_a?(String) && selected_records.include?(':')
181
+ start_idx, end_idx = parse_slice(selected_records, records.length)
182
+ records = records[start_idx...end_idx]
183
+ end
184
+ end
185
+
186
+ # Apply max records
187
+ if max_records && records.length > max_records
188
+ # Show top half and bottom half with ellipsis
189
+ top_rows = (max_records / 2.0).ceil
190
+ bottom_rows = max_records - top_rows
191
+ ellipsis_record = columns.each_with_object({}) { |col, record| record[col[:field]] = '...' }
192
+ records = records[0...top_rows] + [ellipsis_record] + records[-bottom_rows..-1]
193
+ end
194
+
195
+ # Apply max columns
196
+ if max_columns && columns && columns.length > max_columns
197
+ columns = columns[0...max_columns]
198
+ column_fields = columns.map { |col| col[:field] }
199
+ records = records.map do |record|
200
+ column_fields.each_with_object({}) { |field, new_record| new_record[field] = record[field] }
201
+ end
202
+ end
203
+
204
+ { records: records, columns: columns }
205
+ end
206
+
207
+ def parse_slice(slice_str, total_length)
208
+ # Parse Python-style slice notation like "1:3"
209
+ parts = slice_str.split(':')
210
+ start_idx = parts[0].to_i
211
+ end_idx = parts[1] ? parts[1].to_i : total_length
212
+ [start_idx, end_idx]
213
+ end
214
+
215
+ def render_table_markdown(data)
216
+ records = data[:records]
217
+ columns = data[:columns]
218
+
219
+ return '' if records.empty?
220
+
221
+ # If no columns specified, infer from first record
222
+ if columns.empty? && records.first.is_a?(Hash)
223
+ columns = records.first.keys.map { |key| { field: key, header: key } }
224
+ end
225
+
226
+ return '' if columns.empty?
227
+
228
+ # Build markdown table
229
+ result = []
230
+
231
+ # Header row
232
+ headers = columns.map { |col| col[:header] || col[:field] }
233
+ result << "| #{headers.join(' | ')} |"
234
+
235
+ # Separator row
236
+ result << "| #{headers.map { '---' }.join(' | ')} |"
237
+
238
+ # Data rows
239
+ records.each do |record|
240
+ row_values = columns.map do |col|
241
+ value = record[col[:field]]
242
+ value.nil? ? '' : value.to_s
243
+ end
244
+ result << "| #{row_values.join(' | ')} |"
245
+ end
246
+
247
+ result.join("\n")
248
+ end
249
+
250
+ def render_table_raw(data, syntax)
251
+ records = data[:records]
252
+ columns = data[:columns]
253
+
254
+ return '' if records.empty?
255
+
256
+ # If no columns specified, infer from first record
257
+ if columns.empty? && records.first.is_a?(Hash)
258
+ columns = records.first.keys.map { |key| { field: key, header: key } }
259
+ end
260
+
261
+ return '' if columns.empty?
262
+
263
+ # Determine separator
264
+ separator = syntax == 'tsv' ? "\t" : ","
265
+
266
+ # Build raw table
267
+ result = []
268
+
269
+ # Header row
270
+ headers = columns.map { |col| col[:header] || col[:field] }
271
+ result << headers.join(separator)
272
+
273
+ # Data rows
274
+ records.each do |record|
275
+ row_values = columns.map do |col|
276
+ value = record[col[:field]]
277
+ value.nil? ? '' : value.to_s
278
+ end
279
+ result << row_values.join(separator)
280
+ end
281
+
282
+ result.join("\n")
283
+ end
284
+
285
+ def render_table_xml(data)
286
+ records = data[:records]
287
+ columns = data[:columns]
288
+
289
+ return '' if records.empty?
290
+
291
+ # If no columns specified, infer from first record
292
+ if columns.empty? && records.first.is_a?(Hash)
293
+ columns = records.first.keys.map { |key| { field: key, header: key } }
294
+ end
295
+
296
+ return '' if columns.empty?
297
+
298
+ # Build XML table structure
299
+ result = []
300
+ result << '<table>'
301
+ result << ' <thead>'
302
+ result << ' <trow>'
303
+ columns.each do |col|
304
+ result << " <tcell>#{escape_xml(col[:header] || col[:field])}</tcell>"
305
+ end
306
+ result << ' </trow>'
307
+ result << ' </thead>'
308
+ result << ' <tbody>'
309
+
310
+ records.each do |record|
311
+ result << ' <trow>'
312
+ columns.each do |col|
313
+ value = record[col[:field]]
314
+ result << " <tcell>#{escape_xml(value.nil? ? '' : value.to_s)}</tcell>"
315
+ end
316
+ result << ' </trow>'
317
+ end
318
+
319
+ result << ' </tbody>'
320
+ result << '</table>'
321
+
322
+ result.join("\n")
323
+ end
324
+
325
+ def escape_xml(text)
326
+ text.to_s.gsub('&', '&amp;').gsub('<', '&lt;').gsub('>', '&gt;')
327
+ end
328
+ end
329
+
330
+ # Image component
331
+ class ImageComponent < Component
332
+ def render
333
+ apply_stylesheet
334
+
335
+ src = get_attribute('src')
336
+ alt = get_attribute('alt', '')
337
+ syntax = get_attribute('syntax', 'text')
338
+
339
+ if syntax == 'multimedia'
340
+ "[Image: #{src}]#{alt.empty? ? '' : " (#{alt})"}"
341
+ else
342
+ alt.empty? ? "[Image: #{src}]" : alt
343
+ end
344
+ end
345
+ end
346
+ end
@@ -0,0 +1,55 @@
1
+ module Poml
2
+ # Example component
3
+ class ExampleComponent < Component
4
+ def render
5
+ apply_stylesheet
6
+
7
+ content = @element.content.empty? ? render_children : @element.content
8
+ "#{content}\n\n"
9
+ end
10
+ end
11
+
12
+ # Input component (for examples)
13
+ class InputComponent < Component
14
+ def render
15
+ apply_stylesheet
16
+
17
+ content = @element.content.empty? ? render_children : @element.content
18
+ "#{content}\n\n"
19
+ end
20
+ end
21
+
22
+ # Output component (for examples)
23
+ class OutputComponent < Component
24
+ def render
25
+ apply_stylesheet
26
+
27
+ content = @element.content.empty? ? render_children : @element.content
28
+ "#{content}\n\n"
29
+ end
30
+ end
31
+
32
+ # Output format component
33
+ class OutputFormatComponent < Component
34
+ def render
35
+ apply_stylesheet
36
+
37
+ caption = get_attribute('caption', 'Output Format')
38
+ caption_style = get_attribute('captionStyle', 'header')
39
+ content = @element.content.empty? ? render_children : @element.content
40
+
41
+ case caption_style
42
+ when 'header'
43
+ "# #{caption}\n\n#{content}\n\n"
44
+ when 'bold'
45
+ "**#{caption}:** #{content}\n\n"
46
+ when 'plain'
47
+ "#{caption}: #{content}\n\n"
48
+ when 'hidden'
49
+ "#{content}\n\n"
50
+ else
51
+ "# #{caption}\n\n#{content}\n\n"
52
+ end
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,93 @@
1
+ module Poml
2
+ # Role component
3
+ class RoleComponent < Component
4
+ def render
5
+ apply_stylesheet
6
+
7
+ content = @element.content.empty? ? render_children : @element.content
8
+
9
+ if xml_mode?
10
+ render_as_xml('role', content)
11
+ else
12
+ caption = apply_text_transform(get_attribute('caption', 'Role'))
13
+ caption_style = get_attribute('captionStyle', 'header')
14
+
15
+ case caption_style
16
+ when 'header'
17
+ "# #{caption}\n\n#{content}\n\n"
18
+ when 'bold'
19
+ "**#{caption}:** #{content}\n\n"
20
+ when 'plain'
21
+ "#{caption}: #{content}\n\n"
22
+ when 'hidden'
23
+ "#{content}\n\n"
24
+ else
25
+ "# #{caption}\n\n#{content}\n\n"
26
+ end
27
+ end
28
+ end
29
+ end
30
+
31
+ # Task component
32
+ class TaskComponent < Component
33
+ def render
34
+ apply_stylesheet
35
+
36
+ # For mixed content (text + elements), preserve spacing
37
+ content = if @element.children.empty?
38
+ @element.content.strip
39
+ else
40
+ # Don't strip when there are children to preserve spacing between text and elements
41
+ render_children
42
+ end
43
+
44
+ if xml_mode?
45
+ render_as_xml('task', content)
46
+ else
47
+ caption = apply_text_transform(get_attribute('caption', 'Task'))
48
+ caption_style = get_attribute('captionStyle', 'header')
49
+
50
+ case caption_style
51
+ when 'header'
52
+ # Don't add extra newlines if content already ends with newlines
53
+ content_ending = content.end_with?("\n\n") ? "" : "\n\n"
54
+ "# #{caption}\n\n#{content}#{content_ending}"
55
+ when 'bold'
56
+ "**#{caption}:** #{content}\n\n"
57
+ when 'plain'
58
+ "#{caption}: #{content}\n\n"
59
+ when 'hidden'
60
+ content_ending = content.end_with?("\n\n") ? "" : "\n\n"
61
+ "#{content}#{content_ending}"
62
+ else
63
+ content_ending = content.end_with?("\n\n") ? "" : "\n\n"
64
+ "# #{caption}\n\n#{content}#{content_ending}"
65
+ end
66
+ end
67
+ end
68
+ end
69
+
70
+ # Hint component
71
+ class HintComponent < Component
72
+ def render
73
+ apply_stylesheet
74
+
75
+ caption = get_attribute('caption', 'Hint')
76
+ caption_style = get_attribute('captionStyle', 'header')
77
+ content = @element.content.empty? ? render_children : @element.content
78
+
79
+ case caption_style
80
+ when 'header'
81
+ "# #{caption}\n\n#{content}\n\n"
82
+ when 'bold'
83
+ "**#{caption}:** #{content}\n\n"
84
+ when 'plain'
85
+ "#{caption}: #{content}\n\n"
86
+ when 'hidden'
87
+ "#{content}\n\n"
88
+ else
89
+ "# #{caption}\n\n#{content}\n\n"
90
+ end
91
+ end
92
+ end
93
+ end
@@ -0,0 +1,50 @@
1
+ module Poml
2
+ # CP component (custom component with caption)
3
+ class CPComponent < Component
4
+ def render
5
+ apply_stylesheet
6
+
7
+ caption = get_attribute('caption', '')
8
+ caption_serialized = get_attribute('captionSerialized', caption)
9
+
10
+ # Render children with increased header level for nested CPs
11
+ content = if @element.content.empty?
12
+ @context.with_increased_header_level { render_children }
13
+ else
14
+ @element.content
15
+ end
16
+
17
+ if xml_mode?
18
+ # Use captionSerialized for XML tag name, fallback to caption
19
+ tag_name = caption_serialized.empty? ? caption : caption_serialized
20
+ return render_as_xml(tag_name, content) unless tag_name.empty?
21
+ # If no caption, just return content
22
+ return "#{content}\n\n"
23
+ else
24
+ caption_style = get_attribute('captionStyle', 'header')
25
+ # Use captionSerialized for the actual header if provided
26
+ display_caption = caption_serialized.empty? ? caption : caption_serialized
27
+
28
+ # Apply stylesheet text transformation
29
+ display_caption = apply_text_transform(display_caption)
30
+
31
+ return content + "\n\n" if display_caption.empty?
32
+
33
+ case caption_style
34
+ when 'header'
35
+ header_prefix = '#' * @context.header_level
36
+ "#{header_prefix} #{display_caption}\n\n#{content}\n\n"
37
+ when 'bold'
38
+ "**#{display_caption}:** #{content}\n\n"
39
+ when 'plain'
40
+ "#{display_caption}: #{content}\n\n"
41
+ when 'hidden'
42
+ "#{content}\n\n"
43
+ else
44
+ header_prefix = '#' * @context.header_level
45
+ "#{header_prefix} #{display_caption}\n\n#{content}\n\n"
46
+ end
47
+ end
48
+ end
49
+ end
50
+ end