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.
Files changed (48) hide show
  1. checksums.yaml +7 -0
  2. data/.rspec +3 -0
  3. data/.rubocop.yml +55 -0
  4. data/CHANGELOG.md +62 -0
  5. data/Rakefile +75 -0
  6. data/lib/natsuzora/ast.rb +94 -0
  7. data/lib/natsuzora/context.rb +96 -0
  8. data/lib/natsuzora/contract/ast/any.rb +20 -0
  9. data/lib/natsuzora/contract/ast/list.rb +28 -0
  10. data/lib/natsuzora/contract/ast/node.rb +16 -0
  11. data/lib/natsuzora/contract/ast/record.rb +33 -0
  12. data/lib/natsuzora/contract/ast/ref.rb +27 -0
  13. data/lib/natsuzora/contract/ast/scalar.rb +60 -0
  14. data/lib/natsuzora/contract/ast.rb +38 -0
  15. data/lib/natsuzora/contract/compiled_lexer.rb +15 -0
  16. data/lib/natsuzora/contract/diff_marker.rb +15 -0
  17. data/lib/natsuzora/contract/document.rb +45 -0
  18. data/lib/natsuzora/contract/field.rb +62 -0
  19. data/lib/natsuzora/contract/parse_error.rb +16 -0
  20. data/lib/natsuzora/contract/parser.rb +362 -0
  21. data/lib/natsuzora/contract/scalar_type.rb +17 -0
  22. data/lib/natsuzora/contract/type_def.rb +39 -0
  23. data/lib/natsuzora/contract/type_ref_resolver.rb +56 -0
  24. data/lib/natsuzora/contract/validation_target.rb +13 -0
  25. data/lib/natsuzora/contract/validator.rb +179 -0
  26. data/lib/natsuzora/contract.rb +23 -0
  27. data/lib/natsuzora/data/lexers/contract.lkt1 +1 -0
  28. data/lib/natsuzora/data/lexers/template.lkt1 +1 -0
  29. data/lib/natsuzora/data_normalizable.rb +31 -0
  30. data/lib/natsuzora/errors.rb +37 -0
  31. data/lib/natsuzora/html_escape.rb +21 -0
  32. data/lib/natsuzora/lexer/compiled_lexer.rb +15 -0
  33. data/lib/natsuzora/lexer/token_processor.rb +156 -0
  34. data/lib/natsuzora/lexer.rb +95 -0
  35. data/lib/natsuzora/lexer_loader.rb +15 -0
  36. data/lib/natsuzora/lexers/contract.rb +24 -0
  37. data/lib/natsuzora/lexers/template.rb +31 -0
  38. data/lib/natsuzora/parser.rb +419 -0
  39. data/lib/natsuzora/payload.rb +35 -0
  40. data/lib/natsuzora/renderer.rb +132 -0
  41. data/lib/natsuzora/template.rb +34 -0
  42. data/lib/natsuzora/template_loader.rb +118 -0
  43. data/lib/natsuzora/token.rb +20 -0
  44. data/lib/natsuzora/validator.rb +73 -0
  45. data/lib/natsuzora/value.rb +73 -0
  46. data/lib/natsuzora/version.rb +5 -0
  47. data/lib/natsuzora.rb +30 -0
  48. 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
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Natsuzora
4
+ VERSION = '0.4.0'
5
+ 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: []