natsuzora 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/.rspec +3 -0
- data/.rubocop.yml +55 -0
- data/CHANGELOG.md +62 -0
- data/Rakefile +75 -0
- data/lib/natsuzora/ast.rb +94 -0
- data/lib/natsuzora/context.rb +96 -0
- data/lib/natsuzora/contract/ast/any.rb +20 -0
- data/lib/natsuzora/contract/ast/list.rb +28 -0
- data/lib/natsuzora/contract/ast/node.rb +16 -0
- data/lib/natsuzora/contract/ast/record.rb +33 -0
- data/lib/natsuzora/contract/ast/ref.rb +27 -0
- data/lib/natsuzora/contract/ast/scalar.rb +60 -0
- data/lib/natsuzora/contract/ast.rb +38 -0
- data/lib/natsuzora/contract/compiled_lexer.rb +15 -0
- data/lib/natsuzora/contract/diff_marker.rb +15 -0
- data/lib/natsuzora/contract/document.rb +45 -0
- data/lib/natsuzora/contract/field.rb +62 -0
- data/lib/natsuzora/contract/parse_error.rb +16 -0
- data/lib/natsuzora/contract/parser.rb +362 -0
- data/lib/natsuzora/contract/scalar_type.rb +17 -0
- data/lib/natsuzora/contract/type_def.rb +39 -0
- data/lib/natsuzora/contract/type_ref_resolver.rb +56 -0
- data/lib/natsuzora/contract/validation_target.rb +13 -0
- data/lib/natsuzora/contract/validator.rb +179 -0
- data/lib/natsuzora/contract.rb +23 -0
- data/lib/natsuzora/data/lexers/contract.lkt1 +1 -0
- data/lib/natsuzora/data/lexers/template.lkt1 +1 -0
- data/lib/natsuzora/data_normalizable.rb +31 -0
- data/lib/natsuzora/errors.rb +37 -0
- data/lib/natsuzora/html_escape.rb +21 -0
- data/lib/natsuzora/lexer/compiled_lexer.rb +15 -0
- data/lib/natsuzora/lexer/token_processor.rb +156 -0
- data/lib/natsuzora/lexer.rb +95 -0
- data/lib/natsuzora/lexer_loader.rb +15 -0
- data/lib/natsuzora/lexers/contract.rb +24 -0
- data/lib/natsuzora/lexers/template.rb +31 -0
- data/lib/natsuzora/parser.rb +419 -0
- data/lib/natsuzora/payload.rb +35 -0
- data/lib/natsuzora/renderer.rb +132 -0
- data/lib/natsuzora/template.rb +34 -0
- data/lib/natsuzora/template_loader.rb +118 -0
- data/lib/natsuzora/token.rb +20 -0
- data/lib/natsuzora/validator.rb +73 -0
- data/lib/natsuzora/value.rb +73 -0
- data/lib/natsuzora/version.rb +5 -0
- data/lib/natsuzora.rb +30 -0
- metadata +105 -0
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Natsuzora
|
|
4
|
+
class TemplateLoader
|
|
5
|
+
class IncludePathResolver
|
|
6
|
+
def initialize(include_root)
|
|
7
|
+
@include_root = File.expand_path(include_root)
|
|
8
|
+
@include_root_realpath = nil
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def resolve_template_path(name)
|
|
12
|
+
segments = name.split('/').reject(&:empty?)
|
|
13
|
+
segments[-1] = "_#{segments[-1]}"
|
|
14
|
+
"#{File.join(@include_root, *segments)}.ntzr"
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def ensure_within_root!(path)
|
|
18
|
+
candidate = canonicalize_candidate(path)
|
|
19
|
+
root = canonical_root
|
|
20
|
+
return if within_root?(candidate, root)
|
|
21
|
+
|
|
22
|
+
raise IncludeError, "Path traversal detected: #{path}"
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
private
|
|
26
|
+
|
|
27
|
+
def canonical_root
|
|
28
|
+
return @include_root_realpath if @include_root_realpath
|
|
29
|
+
|
|
30
|
+
@include_root_realpath = File.realpath(@include_root)
|
|
31
|
+
rescue Errno::ENOENT, Errno::EACCES => e
|
|
32
|
+
raise IncludeError, "Invalid include root: #{e.message}"
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def canonicalize_candidate(path)
|
|
36
|
+
absolute = File.expand_path(path)
|
|
37
|
+
return File.realpath(absolute) if File.exist?(absolute)
|
|
38
|
+
|
|
39
|
+
existing_parent, missing_segments = split_existing_parent(absolute)
|
|
40
|
+
File.join(File.realpath(existing_parent), *missing_segments)
|
|
41
|
+
rescue Errno::ENOENT, Errno::EACCES => e
|
|
42
|
+
raise IncludeError, "Failed to resolve include path: #{e.message}"
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def split_existing_parent(path)
|
|
46
|
+
cursor = path
|
|
47
|
+
missing_segments = []
|
|
48
|
+
|
|
49
|
+
until File.exist?(cursor)
|
|
50
|
+
missing_segments.unshift(File.basename(cursor))
|
|
51
|
+
parent = File.dirname(cursor)
|
|
52
|
+
break if parent == cursor
|
|
53
|
+
|
|
54
|
+
cursor = parent
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
[cursor, missing_segments]
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def within_root?(path, root)
|
|
61
|
+
return true if path == root
|
|
62
|
+
|
|
63
|
+
root_prefix = root.end_with?(File::SEPARATOR) ? root : "#{root}#{File::SEPARATOR}"
|
|
64
|
+
path.start_with?(root_prefix)
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def initialize(include_root)
|
|
69
|
+
raise ArgumentError, 'include_root is required' if include_root.nil?
|
|
70
|
+
|
|
71
|
+
@path_resolver = IncludePathResolver.new(include_root)
|
|
72
|
+
@cache = {}
|
|
73
|
+
@include_stack = []
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def load(name)
|
|
77
|
+
validate_name!(name)
|
|
78
|
+
|
|
79
|
+
raise IncludeError, "Circular include detected: #{name}" if @include_stack.include?(name)
|
|
80
|
+
|
|
81
|
+
@cache[name] ||= load_and_parse(name)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def with_include(name)
|
|
85
|
+
@include_stack.push(name)
|
|
86
|
+
yield
|
|
87
|
+
rescue StandardError => e
|
|
88
|
+
raise e.class, "#{e.message}\n within include #{include_stack_trace}", e.backtrace
|
|
89
|
+
ensure
|
|
90
|
+
@include_stack.pop
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
private
|
|
94
|
+
|
|
95
|
+
def validate_name!(name)
|
|
96
|
+
Validator.validate_include_name_runtime!(name)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def load_and_parse(name)
|
|
100
|
+
path = @path_resolver.resolve_template_path(name)
|
|
101
|
+
@path_resolver.ensure_within_root!(path)
|
|
102
|
+
|
|
103
|
+
raise IncludeError, "Include file not found: #{name} (#{path})" unless File.file?(path)
|
|
104
|
+
|
|
105
|
+
source = File.read(path, encoding: 'UTF-8')
|
|
106
|
+
tokens = Lexer.new(source).tokenize
|
|
107
|
+
Parser.new(tokens).parse
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def include_stack_trace
|
|
111
|
+
parts = @include_stack.map do |name|
|
|
112
|
+
path = @path_resolver.resolve_template_path(name)
|
|
113
|
+
"#{name} (#{path})"
|
|
114
|
+
end
|
|
115
|
+
(parts + ['current include']).join(' > ')
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Natsuzora
|
|
4
|
+
class Token
|
|
5
|
+
attr_reader :type, :value, :line, :column
|
|
6
|
+
|
|
7
|
+
RESERVED_WORDS = %w[if unless else each as in of unsecure true false null include].freeze
|
|
8
|
+
|
|
9
|
+
def initialize(type, value, line:, column:)
|
|
10
|
+
@type = type
|
|
11
|
+
@value = value
|
|
12
|
+
@line = line
|
|
13
|
+
@column = column
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def inspect
|
|
17
|
+
"#<Token #{type}:#{value.inspect} at #{line}:#{column}>"
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Natsuzora
|
|
4
|
+
# Centralized validation functions for Natsuzora templates
|
|
5
|
+
module Validator
|
|
6
|
+
class << self
|
|
7
|
+
# Validate an identifier (variable name, each binding, include argument key)
|
|
8
|
+
#
|
|
9
|
+
# Rules:
|
|
10
|
+
# - Cannot be a reserved word (if, unless, each, as, unsecure, true, false, null, include)
|
|
11
|
+
# - Cannot start with '_' (reserved for internal use)
|
|
12
|
+
# - Cannot contain '@' (reserved for future use)
|
|
13
|
+
def validate_identifier!(name, line: nil, column: nil)
|
|
14
|
+
raise ReservedWordError.new("'#{name}' is a reserved word", line: line, column: column) if Token::RESERVED_WORDS.include?(name)
|
|
15
|
+
|
|
16
|
+
raise ParseError.new("Identifier cannot start with '_': #{name}", line: line, column: column) if name.start_with?('_')
|
|
17
|
+
|
|
18
|
+
return unless name.include?('@')
|
|
19
|
+
|
|
20
|
+
raise ParseError.new("Identifier cannot contain '@': #{name}", line: line, column: column)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Validate an include name at parse time
|
|
24
|
+
#
|
|
25
|
+
# Lexer ensures each segment follows Identifier rules (starts with letter).
|
|
26
|
+
# This validates additional constraints:
|
|
27
|
+
# - Must start with '/'
|
|
28
|
+
# - Must have at least one segment after '/'
|
|
29
|
+
def validate_include_name_syntax!(name, line: nil, column: nil)
|
|
30
|
+
raise ParseError.new("Include name must start with '/'", line: line, column: column) unless name.start_with?('/')
|
|
31
|
+
|
|
32
|
+
return unless name == '/'
|
|
33
|
+
|
|
34
|
+
raise ParseError.new('Include name must have at least one segment', line: line, column: column)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Validate an include name at load time
|
|
38
|
+
#
|
|
39
|
+
# Defense in depth: re-check basic rules even though lexer enforces them
|
|
40
|
+
def validate_include_name_runtime!(name)
|
|
41
|
+
raise IncludeError, "Include name must start with '/': #{name}" unless name.start_with?('/')
|
|
42
|
+
|
|
43
|
+
# These should be impossible with the new lexer, but check anyway
|
|
44
|
+
return unless name.include?('..') || name.include?('//') || name.include?('\\') || name.include?(':')
|
|
45
|
+
|
|
46
|
+
raise IncludeError, "Invalid include name: #{name}"
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Validate that runtime data conforms to Natsuzora's value type system.
|
|
50
|
+
#
|
|
51
|
+
# Error message:
|
|
52
|
+
# - Float NaN / Infinity → "Invalid number: ..."
|
|
53
|
+
# - Other (non-whole) Float → "Floating point numbers are not supported: ..."
|
|
54
|
+
# - Integer out of safe range → "Integer out of range: ..."
|
|
55
|
+
def validate_data!(data)
|
|
56
|
+
case data
|
|
57
|
+
when Hash
|
|
58
|
+
data.each_value { |v| validate_data!(v) }
|
|
59
|
+
when Array
|
|
60
|
+
data.each { |v| validate_data!(v) }
|
|
61
|
+
when Integer
|
|
62
|
+
return if data.between?(Value::INTEGER_MIN, Value::INTEGER_MAX)
|
|
63
|
+
|
|
64
|
+
raise Natsuzora::TypeError, "Integer out of range: #{data}"
|
|
65
|
+
when Float
|
|
66
|
+
raise Natsuzora::TypeError, "Invalid number: #{data}" unless data.finite?
|
|
67
|
+
|
|
68
|
+
raise Natsuzora::TypeError, "Floating point numbers are not supported: #{data}"
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Natsuzora
|
|
4
|
+
module Value
|
|
5
|
+
INTEGER_MIN = -9_007_199_254_740_991
|
|
6
|
+
INTEGER_MAX = 9_007_199_254_740_991
|
|
7
|
+
|
|
8
|
+
class << self
|
|
9
|
+
# Stringify value (v4.0: null is an error)
|
|
10
|
+
def stringify(value)
|
|
11
|
+
case value
|
|
12
|
+
when String
|
|
13
|
+
value
|
|
14
|
+
when Integer
|
|
15
|
+
validate_integer_range!(value)
|
|
16
|
+
value.to_s
|
|
17
|
+
when NilClass
|
|
18
|
+
raise TypeError, "Cannot stringify null value without '?' modifier"
|
|
19
|
+
when TrueClass, FalseClass
|
|
20
|
+
raise TypeError, 'Cannot stringify boolean value'
|
|
21
|
+
when Array
|
|
22
|
+
raise TypeError, 'Cannot stringify array'
|
|
23
|
+
when Hash
|
|
24
|
+
raise TypeError, 'Cannot stringify object'
|
|
25
|
+
else
|
|
26
|
+
raise TypeError, "Cannot stringify value of type #{value.class}"
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Stringify with nullable modifier (null -> empty string)
|
|
31
|
+
def stringify_nullable(value)
|
|
32
|
+
return '' if value.nil?
|
|
33
|
+
|
|
34
|
+
stringify(value)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Stringify with required modifier (null and empty string are errors)
|
|
38
|
+
def stringify_required(value)
|
|
39
|
+
raise TypeError, "Cannot stringify null value with '!' modifier" if value.nil?
|
|
40
|
+
raise TypeError, "Cannot stringify empty string with '!' modifier" if value == ''
|
|
41
|
+
|
|
42
|
+
stringify(value)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def truthy?(value)
|
|
46
|
+
case value
|
|
47
|
+
when false, nil
|
|
48
|
+
false
|
|
49
|
+
when Integer
|
|
50
|
+
value != 0
|
|
51
|
+
when String, Array, Hash
|
|
52
|
+
!value.empty?
|
|
53
|
+
else
|
|
54
|
+
true
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def ensure_array!(value)
|
|
59
|
+
raise TypeError, "Expected array, got #{value.class}" unless value.is_a?(Array)
|
|
60
|
+
|
|
61
|
+
value
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
private
|
|
65
|
+
|
|
66
|
+
def validate_integer_range!(value)
|
|
67
|
+
return if value.between?(INTEGER_MIN, INTEGER_MAX)
|
|
68
|
+
|
|
69
|
+
raise TypeError, "Integer out of range: #{value}"
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
data/lib/natsuzora.rb
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'natsuzora/version'
|
|
4
|
+
require_relative 'natsuzora/errors'
|
|
5
|
+
require_relative 'natsuzora/token'
|
|
6
|
+
require_relative 'natsuzora/validator'
|
|
7
|
+
require_relative 'natsuzora/html_escape'
|
|
8
|
+
require_relative 'natsuzora/value'
|
|
9
|
+
require_relative 'natsuzora/data_normalizable'
|
|
10
|
+
require_relative 'natsuzora/ast'
|
|
11
|
+
require_relative 'natsuzora/lexer'
|
|
12
|
+
require_relative 'natsuzora/parser'
|
|
13
|
+
require_relative 'natsuzora/context'
|
|
14
|
+
require_relative 'natsuzora/template_loader'
|
|
15
|
+
require_relative 'natsuzora/renderer'
|
|
16
|
+
require_relative 'natsuzora/payload'
|
|
17
|
+
require_relative 'natsuzora/template'
|
|
18
|
+
require_relative 'natsuzora/contract'
|
|
19
|
+
|
|
20
|
+
module Natsuzora
|
|
21
|
+
class << self
|
|
22
|
+
def render(source, data, include_root: nil)
|
|
23
|
+
Template.new(source, include_root: include_root).render(Payload.new(data))
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def parse(source, include_root: nil)
|
|
27
|
+
Template.new(source, include_root: include_root)
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: natsuzora
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.4.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Aozora Bunko
|
|
8
|
+
bindir: bin
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 2026-05-04 00:00:00.000000000 Z
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: lexer_kit
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - ">="
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: 0.5.0
|
|
19
|
+
type: :runtime
|
|
20
|
+
prerelease: false
|
|
21
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
22
|
+
requirements:
|
|
23
|
+
- - ">="
|
|
24
|
+
- !ruby/object:Gem::Version
|
|
25
|
+
version: 0.5.0
|
|
26
|
+
description: Natsuzora is a minimal, display-only template language designed for static
|
|
27
|
+
HTML generation and Rails preview templates.
|
|
28
|
+
email:
|
|
29
|
+
- info@aozora.gr.jp
|
|
30
|
+
executables: []
|
|
31
|
+
extensions: []
|
|
32
|
+
extra_rdoc_files: []
|
|
33
|
+
files:
|
|
34
|
+
- ".rspec"
|
|
35
|
+
- ".rubocop.yml"
|
|
36
|
+
- CHANGELOG.md
|
|
37
|
+
- Rakefile
|
|
38
|
+
- lib/natsuzora.rb
|
|
39
|
+
- lib/natsuzora/ast.rb
|
|
40
|
+
- lib/natsuzora/context.rb
|
|
41
|
+
- lib/natsuzora/contract.rb
|
|
42
|
+
- lib/natsuzora/contract/ast.rb
|
|
43
|
+
- lib/natsuzora/contract/ast/any.rb
|
|
44
|
+
- lib/natsuzora/contract/ast/list.rb
|
|
45
|
+
- lib/natsuzora/contract/ast/node.rb
|
|
46
|
+
- lib/natsuzora/contract/ast/record.rb
|
|
47
|
+
- lib/natsuzora/contract/ast/ref.rb
|
|
48
|
+
- lib/natsuzora/contract/ast/scalar.rb
|
|
49
|
+
- lib/natsuzora/contract/compiled_lexer.rb
|
|
50
|
+
- lib/natsuzora/contract/diff_marker.rb
|
|
51
|
+
- lib/natsuzora/contract/document.rb
|
|
52
|
+
- lib/natsuzora/contract/field.rb
|
|
53
|
+
- lib/natsuzora/contract/parse_error.rb
|
|
54
|
+
- lib/natsuzora/contract/parser.rb
|
|
55
|
+
- lib/natsuzora/contract/scalar_type.rb
|
|
56
|
+
- lib/natsuzora/contract/type_def.rb
|
|
57
|
+
- lib/natsuzora/contract/type_ref_resolver.rb
|
|
58
|
+
- lib/natsuzora/contract/validation_target.rb
|
|
59
|
+
- lib/natsuzora/contract/validator.rb
|
|
60
|
+
- lib/natsuzora/data/lexers/contract.lkt1
|
|
61
|
+
- lib/natsuzora/data/lexers/template.lkt1
|
|
62
|
+
- lib/natsuzora/data_normalizable.rb
|
|
63
|
+
- lib/natsuzora/errors.rb
|
|
64
|
+
- lib/natsuzora/html_escape.rb
|
|
65
|
+
- lib/natsuzora/lexer.rb
|
|
66
|
+
- lib/natsuzora/lexer/compiled_lexer.rb
|
|
67
|
+
- lib/natsuzora/lexer/token_processor.rb
|
|
68
|
+
- lib/natsuzora/lexer_loader.rb
|
|
69
|
+
- lib/natsuzora/lexers/contract.rb
|
|
70
|
+
- lib/natsuzora/lexers/template.rb
|
|
71
|
+
- lib/natsuzora/parser.rb
|
|
72
|
+
- lib/natsuzora/payload.rb
|
|
73
|
+
- lib/natsuzora/renderer.rb
|
|
74
|
+
- lib/natsuzora/template.rb
|
|
75
|
+
- lib/natsuzora/template_loader.rb
|
|
76
|
+
- lib/natsuzora/token.rb
|
|
77
|
+
- lib/natsuzora/validator.rb
|
|
78
|
+
- lib/natsuzora/value.rb
|
|
79
|
+
- lib/natsuzora/version.rb
|
|
80
|
+
homepage: https://github.com/aozorabunko/natsuzora
|
|
81
|
+
licenses:
|
|
82
|
+
- MIT
|
|
83
|
+
metadata:
|
|
84
|
+
homepage_uri: https://github.com/aozorabunko/natsuzora
|
|
85
|
+
source_code_uri: https://github.com/aozorabunko/natsuzora
|
|
86
|
+
changelog_uri: https://github.com/aozorabunko/natsuzora/blob/main/CHANGELOG.md
|
|
87
|
+
rubygems_mfa_required: 'true'
|
|
88
|
+
rdoc_options: []
|
|
89
|
+
require_paths:
|
|
90
|
+
- lib
|
|
91
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
92
|
+
requirements:
|
|
93
|
+
- - ">="
|
|
94
|
+
- !ruby/object:Gem::Version
|
|
95
|
+
version: 3.2.0
|
|
96
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
97
|
+
requirements:
|
|
98
|
+
- - ">="
|
|
99
|
+
- !ruby/object:Gem::Version
|
|
100
|
+
version: '0'
|
|
101
|
+
requirements: []
|
|
102
|
+
rubygems_version: 3.6.2
|
|
103
|
+
specification_version: 4
|
|
104
|
+
summary: Minimal template language for safe HTML generation
|
|
105
|
+
test_files: []
|