prompter-ruby 0.1.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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +12 -0
- data/LICENSE +21 -0
- data/README.md +37 -0
- data/Rakefile +11 -0
- data/lib/prompter_ruby/errors.rb +9 -0
- data/lib/prompter_ruby/library.rb +59 -0
- data/lib/prompter_ruby/nodes/for_node.rb +41 -0
- data/lib/prompter_ruby/nodes/if_node.rb +63 -0
- data/lib/prompter_ruby/nodes/text_node.rb +17 -0
- data/lib/prompter_ruby/nodes/variable_node.rb +49 -0
- data/lib/prompter_ruby/parser.rb +226 -0
- data/lib/prompter_ruby/template.rb +45 -0
- data/lib/prompter_ruby/version.rb +5 -0
- data/lib/prompter_ruby.rb +23 -0
- data/prompter-ruby.gemspec +32 -0
- metadata +87 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 62d43dac53307b79d18f84fcf1c40fd88dbf19a5c337b9e914c13fa010b73dbe
|
|
4
|
+
data.tar.gz: 7e2c23ca40c7178b0821bf105bd4389a33b2f293ca4ae76551b762262b17e9d2
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 630c8c261d232440ac6335e0d479a8933b9e2c8f57379d63a5a94da0af59aabc9cabd1c32e549b07663c14902d8f70dcf3d9cc211832900a6b6f81b61f4e3ff6
|
|
7
|
+
data.tar.gz: 170c646d22cd2e94a1f63772f1772660acd4f33e9cc0215ba5432d29766df0560c9be3114b904cdf1b3c8116fe30226adacb7318a8f95cf749b4265a7da71f89
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.1.0 (2026-03-09)
|
|
4
|
+
|
|
5
|
+
- Initial release
|
|
6
|
+
- Template parser with variables, conditionals, and loops
|
|
7
|
+
- Dot notation for nested variables
|
|
8
|
+
- If/else/endif blocks with truthiness evaluation
|
|
9
|
+
- For/endfor loops over collections
|
|
10
|
+
- Negation support
|
|
11
|
+
- File-based prompt library with versioning
|
|
12
|
+
- Template caching
|
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Johannes Dwi Cahyo
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# prompter-ruby
|
|
2
|
+
|
|
3
|
+
Prompt template engine for Ruby with variables, conditionals, loops, and versioning.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```ruby
|
|
8
|
+
gem "prompter-ruby", "~> 0.1"
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Usage
|
|
12
|
+
|
|
13
|
+
```ruby
|
|
14
|
+
require "prompter_ruby"
|
|
15
|
+
|
|
16
|
+
result = PrompterRuby.render("Hello {{name}}!", name: "World")
|
|
17
|
+
|
|
18
|
+
template = PrompterRuby::Template.new(<<~PROMPT)
|
|
19
|
+
You are {{bot_name}}.
|
|
20
|
+
{% if context %}Context: {{context}}{% endif %}
|
|
21
|
+
{% for rule in rules %}- {{rule}}
|
|
22
|
+
{% endfor %}
|
|
23
|
+
PROMPT
|
|
24
|
+
|
|
25
|
+
result = template.render(
|
|
26
|
+
bot_name: "Max",
|
|
27
|
+
context: "Some info",
|
|
28
|
+
rules: ["Be concise", "Be helpful"]
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
library = PrompterRuby::Library.new("./prompts/")
|
|
32
|
+
template = library.get("rag_answer", version: "v2")
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
## License
|
|
36
|
+
|
|
37
|
+
MIT
|
data/Rakefile
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PrompterRuby
|
|
4
|
+
class Library
|
|
5
|
+
def initialize(path)
|
|
6
|
+
@path = path
|
|
7
|
+
@cache = {}
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def get(name, version: nil)
|
|
11
|
+
cache_key = "#{name}:#{version || "latest"}"
|
|
12
|
+
@cache[cache_key] ||= load_template(name, version: version)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def list
|
|
16
|
+
Dir.glob(File.join(@path, "**/*.{txt,prompt,md}")).map do |f|
|
|
17
|
+
relative = f.sub("#{@path}/", "")
|
|
18
|
+
relative.sub(/\.[^.]+$/, "")
|
|
19
|
+
end.sort
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def clear_cache
|
|
23
|
+
@cache = {}
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
private
|
|
27
|
+
|
|
28
|
+
def load_template(name, version: nil)
|
|
29
|
+
file_path = find_template_file(name, version: version)
|
|
30
|
+
raise TemplateNotFoundError, "Template not found: #{name}" unless file_path
|
|
31
|
+
|
|
32
|
+
source = File.read(file_path)
|
|
33
|
+
Template.new(source)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def find_template_file(name, version: nil)
|
|
37
|
+
extensions = %w[.txt .prompt .md]
|
|
38
|
+
|
|
39
|
+
if version
|
|
40
|
+
extensions.each do |ext|
|
|
41
|
+
path = File.join(@path, "#{name}.#{version}#{ext}")
|
|
42
|
+
return path if File.exist?(path)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
extensions.each do |ext|
|
|
46
|
+
path = File.join(@path, name, "#{version}#{ext}")
|
|
47
|
+
return path if File.exist?(path)
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
extensions.each do |ext|
|
|
52
|
+
path = File.join(@path, "#{name}#{ext}")
|
|
53
|
+
return path if File.exist?(path)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
nil
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PrompterRuby
|
|
4
|
+
module Nodes
|
|
5
|
+
class ForNode
|
|
6
|
+
def initialize(item_name, collection_name, body_nodes)
|
|
7
|
+
@item_name = item_name.strip
|
|
8
|
+
@collection_name = collection_name.strip
|
|
9
|
+
@body_nodes = body_nodes
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def render(context)
|
|
13
|
+
collection = resolve_collection(context)
|
|
14
|
+
return "" unless collection.is_a?(Enumerable)
|
|
15
|
+
|
|
16
|
+
collection.map do |item|
|
|
17
|
+
loop_context = context.merge(@item_name.to_sym => item)
|
|
18
|
+
@body_nodes.map { |n| n.render(loop_context) }.join
|
|
19
|
+
end.join
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
private
|
|
23
|
+
|
|
24
|
+
def resolve_collection(context)
|
|
25
|
+
parts = @collection_name.split(".")
|
|
26
|
+
value = context
|
|
27
|
+
|
|
28
|
+
parts.each do |part|
|
|
29
|
+
if value.is_a?(Hash)
|
|
30
|
+
key = value.key?(part.to_sym) ? part.to_sym : part
|
|
31
|
+
value = value[key]
|
|
32
|
+
else
|
|
33
|
+
return []
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
value
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PrompterRuby
|
|
4
|
+
module Nodes
|
|
5
|
+
class IfNode
|
|
6
|
+
def initialize(condition, true_nodes, false_nodes = [])
|
|
7
|
+
@condition = condition.strip
|
|
8
|
+
@true_nodes = true_nodes
|
|
9
|
+
@false_nodes = false_nodes
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def render(context)
|
|
13
|
+
if evaluate_condition(context)
|
|
14
|
+
@true_nodes.map { |n| n.render(context) }.join
|
|
15
|
+
else
|
|
16
|
+
@false_nodes.map { |n| n.render(context) }.join
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
private
|
|
21
|
+
|
|
22
|
+
def evaluate_condition(context)
|
|
23
|
+
negate = false
|
|
24
|
+
condition = @condition
|
|
25
|
+
|
|
26
|
+
if condition.start_with?("not ")
|
|
27
|
+
negate = true
|
|
28
|
+
condition = condition.sub("not ", "").strip
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
value = resolve_value(condition, context)
|
|
32
|
+
result = truthy?(value)
|
|
33
|
+
negate ? !result : result
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def resolve_value(name, context)
|
|
37
|
+
parts = name.split(".")
|
|
38
|
+
value = context
|
|
39
|
+
|
|
40
|
+
parts.each do |part|
|
|
41
|
+
if value.is_a?(Hash)
|
|
42
|
+
key = value.key?(part.to_sym) ? part.to_sym : part
|
|
43
|
+
value = value[key]
|
|
44
|
+
elsif value.respond_to?(part)
|
|
45
|
+
value = value.send(part)
|
|
46
|
+
else
|
|
47
|
+
return nil
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
value
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def truthy?(value)
|
|
55
|
+
return false if value.nil?
|
|
56
|
+
return false if value == false
|
|
57
|
+
return false if value.respond_to?(:empty?) && value.empty?
|
|
58
|
+
|
|
59
|
+
true
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PrompterRuby
|
|
4
|
+
module Nodes
|
|
5
|
+
class VariableNode
|
|
6
|
+
attr_reader :name
|
|
7
|
+
|
|
8
|
+
def initialize(name)
|
|
9
|
+
@name = name.strip
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def render(context)
|
|
13
|
+
value = resolve(context)
|
|
14
|
+
|
|
15
|
+
if value.nil? && context[:__strict__]
|
|
16
|
+
raise UndefinedVariableError, "Undefined variable: '#{@name}'"
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
value.to_s
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
private
|
|
23
|
+
|
|
24
|
+
def resolve(context)
|
|
25
|
+
parts = @name.split(".")
|
|
26
|
+
value = context
|
|
27
|
+
|
|
28
|
+
parts.each do |part|
|
|
29
|
+
next if part == "__strict__"
|
|
30
|
+
|
|
31
|
+
if value.is_a?(Hash)
|
|
32
|
+
key = value.key?(part.to_sym) ? part.to_sym : part
|
|
33
|
+
if value.key?(key)
|
|
34
|
+
value = value[key]
|
|
35
|
+
else
|
|
36
|
+
return nil
|
|
37
|
+
end
|
|
38
|
+
elsif value.respond_to?(part)
|
|
39
|
+
value = value.send(part)
|
|
40
|
+
else
|
|
41
|
+
return nil
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
value
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PrompterRuby
|
|
4
|
+
class Parser
|
|
5
|
+
VARIABLE_PATTERN = /\{\{(.*?)\}\}/
|
|
6
|
+
TAG_PATTERN = /\{%(.*?)%\}/
|
|
7
|
+
|
|
8
|
+
def parse(source)
|
|
9
|
+
tokens = tokenize(source)
|
|
10
|
+
tokens = strip_tag_whitespace(tokens)
|
|
11
|
+
parse_nodes(tokens, stop_tags: [], open_tag: nil, open_line: nil)
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
private
|
|
15
|
+
|
|
16
|
+
def tokenize(source)
|
|
17
|
+
tokens = []
|
|
18
|
+
scanner = source
|
|
19
|
+
offset = 0
|
|
20
|
+
|
|
21
|
+
while scanner.length > 0
|
|
22
|
+
var_match = scanner.match(VARIABLE_PATTERN)
|
|
23
|
+
tag_match = scanner.match(TAG_PATTERN)
|
|
24
|
+
|
|
25
|
+
next_match = nil
|
|
26
|
+
next_pos = scanner.length
|
|
27
|
+
|
|
28
|
+
if var_match && var_match.begin(0) < next_pos
|
|
29
|
+
next_match = :variable
|
|
30
|
+
next_pos = var_match.begin(0)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
if tag_match && tag_match.begin(0) < next_pos
|
|
34
|
+
next_match = :tag
|
|
35
|
+
next_pos = tag_match.begin(0)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
if next_pos > 0
|
|
39
|
+
tokens << [:text, scanner[0...next_pos], line_number(source, offset)]
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
case next_match
|
|
43
|
+
when :variable
|
|
44
|
+
tokens << [:variable, var_match[1], line_number(source, offset + var_match.begin(0))]
|
|
45
|
+
offset += var_match.end(0)
|
|
46
|
+
scanner = scanner[var_match.end(0)..]
|
|
47
|
+
when :tag
|
|
48
|
+
tokens << [:tag, tag_match[1].strip, line_number(source, offset + tag_match.begin(0))]
|
|
49
|
+
offset += tag_match.end(0)
|
|
50
|
+
scanner = scanner[tag_match.end(0)..]
|
|
51
|
+
else
|
|
52
|
+
break
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
tokens
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def line_number(source, offset)
|
|
60
|
+
source[0...offset].count("\n") + 1
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Strip whitespace around tag-only lines.
|
|
64
|
+
# If a tag sits alone on a line (only whitespace before/after it on that line),
|
|
65
|
+
# remove that whitespace and the trailing newline so the tag line vanishes.
|
|
66
|
+
def strip_tag_whitespace(tokens)
|
|
67
|
+
result = []
|
|
68
|
+
|
|
69
|
+
tokens.each_with_index do |token, i|
|
|
70
|
+
if token[0] == :tag
|
|
71
|
+
# Check if preceding text ends with only whitespace since last newline
|
|
72
|
+
# and following text starts with only a newline (possibly preceded by whitespace)
|
|
73
|
+
prev_idx = result.length - 1
|
|
74
|
+
next_idx = i + 1
|
|
75
|
+
|
|
76
|
+
prev_is_blank_tail = false
|
|
77
|
+
next_is_blank_head = false
|
|
78
|
+
|
|
79
|
+
# Check text before tag: ends with \n followed by only spaces/tabs (or is start of string with only spaces/tabs)
|
|
80
|
+
if prev_idx >= 0 && result[prev_idx][0] == :text
|
|
81
|
+
text = result[prev_idx][1]
|
|
82
|
+
prev_is_blank_tail = text.match?(/(?:\A|(?<=\n))[ \t]*\z/)
|
|
83
|
+
elsif prev_idx < 0
|
|
84
|
+
# Tag is at the very start
|
|
85
|
+
prev_is_blank_tail = true
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Check text after tag: starts with optional spaces/tabs then \n (or is end of string)
|
|
89
|
+
if next_idx < tokens.length && tokens[next_idx][0] == :text
|
|
90
|
+
text = tokens[next_idx][1]
|
|
91
|
+
next_is_blank_head = text.match?(/\A[ \t]*\n/) || text.match?(/\A[ \t]*\z/)
|
|
92
|
+
elsif next_idx >= tokens.length
|
|
93
|
+
# Tag is at the very end
|
|
94
|
+
next_is_blank_head = true
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
if prev_is_blank_tail && next_is_blank_head
|
|
98
|
+
# Trim trailing whitespace from previous text node (after last newline)
|
|
99
|
+
if prev_idx >= 0 && result[prev_idx][0] == :text
|
|
100
|
+
text = result[prev_idx][1]
|
|
101
|
+
trimmed = text.sub(/(?:(?<=\n)|(?:\A))[ \t]*\z/, "")
|
|
102
|
+
if trimmed.empty?
|
|
103
|
+
result.pop
|
|
104
|
+
else
|
|
105
|
+
result[prev_idx] = [:text, trimmed, result[prev_idx][2]]
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
result << token
|
|
110
|
+
|
|
111
|
+
# Mark next text token for trimming (leading whitespace + first newline)
|
|
112
|
+
if next_idx < tokens.length && tokens[next_idx][0] == :text
|
|
113
|
+
text = tokens[next_idx][1]
|
|
114
|
+
trimmed = text.sub(/\A[ \t]*\n/, "")
|
|
115
|
+
if trimmed.empty?
|
|
116
|
+
# Skip this text token entirely - will be handled by replacing in tokens
|
|
117
|
+
tokens[next_idx] = [:text_skip, "", tokens[next_idx][2]]
|
|
118
|
+
else
|
|
119
|
+
tokens[next_idx] = [:text, trimmed, tokens[next_idx][2]]
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
else
|
|
123
|
+
result << token
|
|
124
|
+
end
|
|
125
|
+
elsif token[0] == :text_skip
|
|
126
|
+
# Skip tokens marked for removal
|
|
127
|
+
next
|
|
128
|
+
else
|
|
129
|
+
result << token
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
result
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def parse_nodes(tokens, stop_tags:, open_tag:, open_line:)
|
|
137
|
+
nodes = []
|
|
138
|
+
index = 0
|
|
139
|
+
|
|
140
|
+
while index < tokens.length
|
|
141
|
+
type, value, line = tokens[index]
|
|
142
|
+
|
|
143
|
+
case type
|
|
144
|
+
when :text
|
|
145
|
+
nodes << Nodes::TextNode.new(value)
|
|
146
|
+
index += 1
|
|
147
|
+
when :variable
|
|
148
|
+
nodes << Nodes::VariableNode.new(value)
|
|
149
|
+
index += 1
|
|
150
|
+
when :tag
|
|
151
|
+
if stop_tags.any? { |tag| value.start_with?(tag) }
|
|
152
|
+
return [nodes, index]
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
if value.start_with?("if ")
|
|
156
|
+
condition = value.sub("if ", "").strip
|
|
157
|
+
result = parse_nodes(tokens[(index + 1)..], stop_tags: ["else", "elsif", "endif"], open_tag: "if", open_line: line)
|
|
158
|
+
|
|
159
|
+
if result.is_a?(Array) && result.length == 2
|
|
160
|
+
true_nodes, end_index = result
|
|
161
|
+
else
|
|
162
|
+
raise ParseError, "Unclosed 'if' block starting at line #{line}"
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
index = index + 1 + end_index
|
|
166
|
+
|
|
167
|
+
false_nodes = []
|
|
168
|
+
if tokens[index] && tokens[index][1]&.start_with?("else")
|
|
169
|
+
else_line = tokens[index][2]
|
|
170
|
+
result = parse_nodes(tokens[(index + 1)..], stop_tags: ["endif"], open_tag: "else", open_line: else_line)
|
|
171
|
+
|
|
172
|
+
if result.is_a?(Array) && result.length == 2
|
|
173
|
+
false_nodes, end_index = result
|
|
174
|
+
else
|
|
175
|
+
raise ParseError, "Unclosed 'else' block starting at line #{else_line}"
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
index = index + 1 + end_index
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
# Verify we actually landed on endif
|
|
182
|
+
unless tokens[index] && tokens[index][1]&.start_with?("endif")
|
|
183
|
+
raise ParseError, "Unclosed 'if' block starting at line #{line}"
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
index += 1
|
|
187
|
+
|
|
188
|
+
nodes << Nodes::IfNode.new(condition, true_nodes, false_nodes)
|
|
189
|
+
elsif value.start_with?("for ")
|
|
190
|
+
match = value.match(/for\s+(\w+)\s+in\s+(\S+)/)
|
|
191
|
+
raise ParseError, "Invalid for syntax: #{value}" unless match
|
|
192
|
+
|
|
193
|
+
item_name = match[1]
|
|
194
|
+
collection_name = match[2]
|
|
195
|
+
|
|
196
|
+
result = parse_nodes(tokens[(index + 1)..], stop_tags: ["endfor"], open_tag: "for", open_line: line)
|
|
197
|
+
|
|
198
|
+
if result.is_a?(Array) && result.length == 2
|
|
199
|
+
body_nodes, end_index = result
|
|
200
|
+
else
|
|
201
|
+
raise ParseError, "Unclosed 'for' block starting at line #{line}"
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
# Verify we actually landed on endfor
|
|
205
|
+
unless tokens[index + 1 + end_index] && tokens[index + 1 + end_index][1]&.start_with?("endfor")
|
|
206
|
+
raise ParseError, "Unclosed 'for' block starting at line #{line}"
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
index = index + 1 + end_index + 1
|
|
210
|
+
|
|
211
|
+
nodes << Nodes::ForNode.new(item_name, collection_name, body_nodes)
|
|
212
|
+
else
|
|
213
|
+
nodes << Nodes::TextNode.new("{%#{value}%}")
|
|
214
|
+
index += 1
|
|
215
|
+
end
|
|
216
|
+
end
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
if stop_tags.any?
|
|
220
|
+
raise ParseError, "Unclosed '#{open_tag}' block starting at line #{open_line}"
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
nodes
|
|
224
|
+
end
|
|
225
|
+
end
|
|
226
|
+
end
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PrompterRuby
|
|
4
|
+
class Template
|
|
5
|
+
attr_reader :source
|
|
6
|
+
|
|
7
|
+
def initialize(source)
|
|
8
|
+
raise ArgumentError, "Template source cannot be nil" if source.nil?
|
|
9
|
+
|
|
10
|
+
@source = source
|
|
11
|
+
@parser = Parser.new
|
|
12
|
+
@nodes = @parser.parse(source)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def render(variables = {})
|
|
16
|
+
raise ArgumentError, "Variables must be a Hash" unless variables.is_a?(Hash)
|
|
17
|
+
|
|
18
|
+
context = normalize_keys(variables)
|
|
19
|
+
|
|
20
|
+
if context.delete(:strict)
|
|
21
|
+
context[:__strict__] = true
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
@nodes.map { |node| node.render(context) }.join
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def valid?
|
|
28
|
+
validate!
|
|
29
|
+
true
|
|
30
|
+
rescue ParseError
|
|
31
|
+
false
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def validate!
|
|
35
|
+
@parser.parse(@source)
|
|
36
|
+
true
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
private
|
|
40
|
+
|
|
41
|
+
def normalize_keys(hash)
|
|
42
|
+
hash.transform_keys(&:to_sym)
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "prompter_ruby/version"
|
|
4
|
+
require_relative "prompter_ruby/errors"
|
|
5
|
+
require_relative "prompter_ruby/nodes/text_node"
|
|
6
|
+
require_relative "prompter_ruby/nodes/variable_node"
|
|
7
|
+
require_relative "prompter_ruby/nodes/if_node"
|
|
8
|
+
require_relative "prompter_ruby/nodes/for_node"
|
|
9
|
+
require_relative "prompter_ruby/parser"
|
|
10
|
+
require_relative "prompter_ruby/template"
|
|
11
|
+
require_relative "prompter_ruby/library"
|
|
12
|
+
|
|
13
|
+
module PrompterRuby
|
|
14
|
+
class << self
|
|
15
|
+
def render(source, variables = {})
|
|
16
|
+
Template.new(source).render(variables)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def library(path)
|
|
20
|
+
Library.new(path)
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "lib/prompter_ruby/version"
|
|
4
|
+
|
|
5
|
+
Gem::Specification.new do |spec|
|
|
6
|
+
spec.name = "prompter-ruby"
|
|
7
|
+
spec.version = PrompterRuby::VERSION
|
|
8
|
+
spec.authors = ["Johannes Dwi Cahyo"]
|
|
9
|
+
spec.email = ["johannes@example.com"]
|
|
10
|
+
spec.summary = "Prompt template engine for Ruby"
|
|
11
|
+
spec.description = "Shared prompt template engine with variables, conditionals, loops, and prompt versioning. Replaces ad-hoc templating across LLM gems."
|
|
12
|
+
spec.homepage = "https://github.com/johannesdwicahyo/prompter-ruby"
|
|
13
|
+
spec.license = "MIT"
|
|
14
|
+
spec.required_ruby_version = ">= 3.0.0"
|
|
15
|
+
|
|
16
|
+
spec.metadata["homepage_uri"] = spec.homepage
|
|
17
|
+
spec.metadata["source_code_uri"] = spec.homepage
|
|
18
|
+
spec.metadata["changelog_uri"] = "#{spec.homepage}/blob/main/CHANGELOG.md"
|
|
19
|
+
|
|
20
|
+
spec.files = Dir[
|
|
21
|
+
"lib/**/*.rb",
|
|
22
|
+
"README.md",
|
|
23
|
+
"LICENSE",
|
|
24
|
+
"CHANGELOG.md",
|
|
25
|
+
"Rakefile",
|
|
26
|
+
"prompter-ruby.gemspec"
|
|
27
|
+
]
|
|
28
|
+
spec.require_paths = ["lib"]
|
|
29
|
+
|
|
30
|
+
spec.add_development_dependency "minitest", "~> 5.0"
|
|
31
|
+
spec.add_development_dependency "rake", "~> 13.0"
|
|
32
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: prompter-ruby
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.1
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Johannes Dwi Cahyo
|
|
8
|
+
bindir: bin
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: minitest
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - "~>"
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: '5.0'
|
|
19
|
+
type: :development
|
|
20
|
+
prerelease: false
|
|
21
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
22
|
+
requirements:
|
|
23
|
+
- - "~>"
|
|
24
|
+
- !ruby/object:Gem::Version
|
|
25
|
+
version: '5.0'
|
|
26
|
+
- !ruby/object:Gem::Dependency
|
|
27
|
+
name: rake
|
|
28
|
+
requirement: !ruby/object:Gem::Requirement
|
|
29
|
+
requirements:
|
|
30
|
+
- - "~>"
|
|
31
|
+
- !ruby/object:Gem::Version
|
|
32
|
+
version: '13.0'
|
|
33
|
+
type: :development
|
|
34
|
+
prerelease: false
|
|
35
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
36
|
+
requirements:
|
|
37
|
+
- - "~>"
|
|
38
|
+
- !ruby/object:Gem::Version
|
|
39
|
+
version: '13.0'
|
|
40
|
+
description: Shared prompt template engine with variables, conditionals, loops, and
|
|
41
|
+
prompt versioning. Replaces ad-hoc templating across LLM gems.
|
|
42
|
+
email:
|
|
43
|
+
- johannes@example.com
|
|
44
|
+
executables: []
|
|
45
|
+
extensions: []
|
|
46
|
+
extra_rdoc_files: []
|
|
47
|
+
files:
|
|
48
|
+
- CHANGELOG.md
|
|
49
|
+
- LICENSE
|
|
50
|
+
- README.md
|
|
51
|
+
- Rakefile
|
|
52
|
+
- lib/prompter_ruby.rb
|
|
53
|
+
- lib/prompter_ruby/errors.rb
|
|
54
|
+
- lib/prompter_ruby/library.rb
|
|
55
|
+
- lib/prompter_ruby/nodes/for_node.rb
|
|
56
|
+
- lib/prompter_ruby/nodes/if_node.rb
|
|
57
|
+
- lib/prompter_ruby/nodes/text_node.rb
|
|
58
|
+
- lib/prompter_ruby/nodes/variable_node.rb
|
|
59
|
+
- lib/prompter_ruby/parser.rb
|
|
60
|
+
- lib/prompter_ruby/template.rb
|
|
61
|
+
- lib/prompter_ruby/version.rb
|
|
62
|
+
- prompter-ruby.gemspec
|
|
63
|
+
homepage: https://github.com/johannesdwicahyo/prompter-ruby
|
|
64
|
+
licenses:
|
|
65
|
+
- MIT
|
|
66
|
+
metadata:
|
|
67
|
+
homepage_uri: https://github.com/johannesdwicahyo/prompter-ruby
|
|
68
|
+
source_code_uri: https://github.com/johannesdwicahyo/prompter-ruby
|
|
69
|
+
changelog_uri: https://github.com/johannesdwicahyo/prompter-ruby/blob/main/CHANGELOG.md
|
|
70
|
+
rdoc_options: []
|
|
71
|
+
require_paths:
|
|
72
|
+
- lib
|
|
73
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
74
|
+
requirements:
|
|
75
|
+
- - ">="
|
|
76
|
+
- !ruby/object:Gem::Version
|
|
77
|
+
version: 3.0.0
|
|
78
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
79
|
+
requirements:
|
|
80
|
+
- - ">="
|
|
81
|
+
- !ruby/object:Gem::Version
|
|
82
|
+
version: '0'
|
|
83
|
+
requirements: []
|
|
84
|
+
rubygems_version: 3.6.9
|
|
85
|
+
specification_version: 4
|
|
86
|
+
summary: Prompt template engine for Ruby
|
|
87
|
+
test_files: []
|