sade 0.1.0.pre

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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 9807167655e58ad5e58b785bc420b4135a435759376185086d9da0a5ec4d15d8
4
+ data.tar.gz: aa10c7e97c88928a943371aaedcb8a3e6df6b8a326cb5ab893af9483728217a3
5
+ SHA512:
6
+ metadata.gz: 619cd3fca2000ad0dab421dc020b888d72ce55d72495fb46b2c0ab5a73c0271d6051cd29bd442aa2e48a5fa89d494b3700124777f9a1567632fd19dd3032477b
7
+ data.tar.gz: 9fe60a2aa87cf1662cdf2e337be27caf3a9ccff2d12a6758d2d0248c51ebc31115f7ad67666e86ef955982095f04cd167c323279e917f52192cfe7ee9a4d0c96
data/LICENSE ADDED
@@ -0,0 +1,24 @@
1
+ BSD 2-Clause License
2
+
3
+ Copyright (c) 2026, Takanobu Maekawa
4
+
5
+ Redistribution and use in source and binary forms, with or without
6
+ modification, are permitted provided that the following conditions are met:
7
+
8
+ 1. Redistributions of source code must retain the above copyright notice, this
9
+ list of conditions and the following disclaimer.
10
+
11
+ 2. Redistributions in binary form must reproduce the above copyright notice,
12
+ this list of conditions and the following disclaimer in the documentation
13
+ and/or other materials provided with the distribution.
14
+
15
+ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
16
+ AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
17
+ IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
18
+ DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
19
+ FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
20
+ DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
21
+ SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
22
+ CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
23
+ OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
24
+ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
data/README.md ADDED
@@ -0,0 +1,14 @@
1
+ # Sade
2
+
3
+ An HTML template language for static page generation.
4
+
5
+ ---
6
+ Sade is a template language designed to make writing HTML easier and more readable.
7
+
8
+ - It uses an S-expression based syntax, which requires parentheses, but allows flexible
9
+ indentation and line breaks.
10
+
11
+ - Templates and raw HTML can be embedded directly, making reuse straightforward.
12
+
13
+ - Additional shorthand notations help reduce the number of
14
+ parentheses, and the id/class shortcuts follow the style popularized by Haml.
data/VERSION ADDED
@@ -0,0 +1 @@
1
+ 0.1.0.pre
data/bin/sade ADDED
@@ -0,0 +1,179 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "optparse"
5
+ require "json"
6
+ require "sade"
7
+ require "sade/version"
8
+
9
+ #
10
+ # Struct → JSON 変換(AST 用)
11
+ #
12
+ def ast_to_h(node)
13
+ case node
14
+ when Struct
15
+ {
16
+ "__type__" => node.class.name.split("::").last,
17
+ **node.to_h.transform_values { |v| ast_to_h(v) }
18
+ }
19
+ when Array
20
+ node.map { |v| ast_to_h(v) }
21
+ else
22
+ node
23
+ end
24
+ end
25
+
26
+ #
27
+ # CLI オプション
28
+ #
29
+ options = {
30
+ mode: :render,
31
+ escape: true,
32
+ doctype: false,
33
+ include_path: ".",
34
+ import_path: ".",
35
+ context_json: nil,
36
+ context_file: nil
37
+ }
38
+
39
+ parser = OptionParser.new do |opt|
40
+ opt.banner = "Usage: sade [options] < input.sd > output.html"
41
+
42
+ opt.on("-l", "--lex", "Run lexer only (JSON output)") do
43
+ options[:mode] = :lex
44
+ end
45
+
46
+ opt.on("-p", "--parse", "Run parser only (JSON output)") do
47
+ options[:mode] = :parse
48
+ end
49
+
50
+ opt.on("--noescape", "Disable HTML escaping") do
51
+ options[:escape] = false
52
+ end
53
+
54
+ opt.on("-d", "--doctype", "Add <!DOCTYPE html> to output") do
55
+ options[:doctype] = true
56
+ end
57
+
58
+ opt.on("--include-path PATH", "Base directory for %include") do |v|
59
+ options[:include_path] = v
60
+ end
61
+
62
+ opt.on("--import-path PATH", "Base directory for %import") do |v|
63
+ options[:import_path] = v
64
+ end
65
+
66
+ opt.on("--context-json JSON", "Inline JSON context") do |v|
67
+ options[:context_json] = v
68
+ end
69
+
70
+ opt.on("--context-file PATH", "Load JSON context from file") do |v|
71
+ options[:context_file] = v
72
+ end
73
+
74
+ opt.on("-v", "--version") do
75
+ puts Sade::VERSION
76
+ exit(0)
77
+ end
78
+
79
+ opt.on("-h", "--help", "Show help") do
80
+ puts opt
81
+ exit 0
82
+ end
83
+ end
84
+
85
+ begin
86
+ parser.parse!
87
+ rescue OptionParser::InvalidOption => e
88
+ warn e.message
89
+ warn parser
90
+ exit 1
91
+ end
92
+
93
+ #
94
+ # context の排他チェック
95
+ #
96
+ if options[:context_json] && options[:context_file]
97
+ warn "Error: --context-json と --context-file は同時に指定できません"
98
+ exit 1
99
+ end
100
+
101
+ #
102
+ # context の読み込み
103
+ #
104
+ context = {}
105
+
106
+ if options[:context_json]
107
+ begin
108
+ context = JSON.parse(options[:context_json])
109
+ rescue JSON::ParserError => e
110
+ warn "JSON parse error in --context-json: #{e.message}"
111
+ exit 1
112
+ end
113
+ elsif options[:context_file]
114
+ begin
115
+ context = JSON.parse(File.read(options[:context_file]))
116
+ rescue Errno::ENOENT
117
+ warn "JSON file not found: #{options[:context_file]}"
118
+ exit 1
119
+ rescue JSON::ParserError => e
120
+ warn "JSON parse error in file #{options[:context_file]}: #{e.message}"
121
+ exit 1
122
+ end
123
+ end
124
+
125
+ #
126
+ # 標準入力を読む
127
+ #
128
+ input = STDIN.read
129
+
130
+ #
131
+ # Engine 準備
132
+ #
133
+ engine = Sade::Engine.new(
134
+ include_path: options[:include_path],
135
+ import_path: options[:import_path]
136
+ )
137
+
138
+ lexer = engine.lexer
139
+ parser = engine.parser
140
+
141
+ #
142
+ # lex モード
143
+ #
144
+ if options[:mode] == :lex
145
+ token_stream = lexer.lex(input)
146
+ it = token_stream.begin
147
+ until it.eof?
148
+ puts JSON.pretty_generate(it.current)
149
+ it.next
150
+ end
151
+ exit 0
152
+ end
153
+
154
+ #
155
+ # parse モード
156
+ #
157
+ if options[:mode] == :parse
158
+ tokens = lexer.lex(input)
159
+ ast = parser.parse(tokens)
160
+ puts JSON.pretty_generate(ast_to_h(ast))
161
+ exit 0
162
+ end
163
+
164
+ #
165
+ # render モード(デフォルト)
166
+ #
167
+ begin
168
+ html = engine.render(
169
+ input,
170
+ context,
171
+ escape: options[:escape],
172
+ doctype: options[:doctype]
173
+ )
174
+
175
+ puts html
176
+ rescue => e
177
+ puts e.message
178
+ exit(1)
179
+ end
@@ -0,0 +1,67 @@
1
+ require_relative 'attr_read_helper'
2
+ require_relative 'parser_helper'
3
+
4
+ module Sade
5
+ class AttrBlockReader
6
+ attr_reader :parser
7
+
8
+ include AttrReadHelper
9
+ include ParserHelper
10
+
11
+ def initialize(parser) = @parser = parser
12
+
13
+ def read(it)
14
+ return nil unless it.lbrace?
15
+
16
+ attrs = {}
17
+
18
+ it.next
19
+
20
+ while not it.rbrace?
21
+ eof_error(it) if it.eof?
22
+
23
+ it.skip_space
24
+
25
+ name = read_attr_name(it)
26
+
27
+ it.skip_space
28
+
29
+ val = read_attr_val(it)
30
+
31
+ merge_attrs(attrs, name, val) if name && val
32
+
33
+ it.skip_space
34
+ end
35
+
36
+ return nil if attrs.empty?
37
+
38
+ attrs
39
+ end
40
+
41
+ def read_attr_name(it)
42
+ name = String.new
43
+ until it.rbrace? || it.space? || it.eof? do
44
+ type = it.type
45
+ val = it.val
46
+ error("'#{val}' cannot be included as the attribute name", it) unless valid_name_token?(type)
47
+
48
+ name << val
49
+ it.next
50
+ end
51
+
52
+ error("collon(:) should be placed at the end of '#{name}'", it) if name[-1] !=":"
53
+
54
+ name.chop.to_sym
55
+ end
56
+
57
+ def read_attr_val(it) = parser.attr_value_reader.read(it)
58
+
59
+ def merge_attrs(attrs, name, node)
60
+ if name == :class
61
+ attrs[:class] = attrs.fetch(:class, []) + [node]
62
+ else
63
+ attrs[name] = node
64
+ end
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,33 @@
1
+ module Sade
2
+ module AttrReadHelper
3
+ def unpermitted_attr_name_token_types
4
+ @unpermitted_attr_name_token_types ||= Set.new %i[
5
+ lparen rparen lbrace rbrace equal lt gt
6
+ ]
7
+ end
8
+
9
+ def unpermitted_attr_val_token_types
10
+ @unpermitted_attr_val_token_types ||= Set.new %i[
11
+ space lparen rparen lbrace rbrace eof
12
+ ]
13
+ end
14
+
15
+ def unpermitted_attr_val_token_types_for_shortcut
16
+ @unpermitted_attr_val_token_types_for_shortcut ||= Set.new %i[
17
+ dot hash space lparen rparen lbrace rbrace eof slash
18
+ ]
19
+ end
20
+
21
+ def valid_name_token?(type)
22
+ not unpermitted_attr_name_token_types.include?(type)
23
+ end
24
+
25
+ def valid_value_token?(type)
26
+ not unpermitted_attr_val_token_types.include?(type)
27
+ end
28
+
29
+ def valid_value_token_for_shortcut?(type)
30
+ not unpermitted_attr_val_token_types_for_shortcut.include?(type)
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,76 @@
1
+ require_relative 'attr_read_helper'
2
+ require_relative 'parser_helper'
3
+
4
+ module Sade
5
+ class AttrShortcutReader
6
+ attr_reader :parser
7
+
8
+ include AttrReadHelper
9
+ include ParserHelper
10
+
11
+ def initialize(parser) = @parser = parser
12
+
13
+ def read(it, aditional_delimiters: [])
14
+ attrs = {}
15
+
16
+ while (hash?(it) || dot?(it)) && valid_attr_val?(it)
17
+ read_id(it, attrs) if hash?(it)
18
+ read_class(it, attrs) if dot?(it)
19
+ skip_space(it) if next_shortcut?(it)
20
+ end
21
+
22
+ return nil if attrs.empty?
23
+
24
+ attrs
25
+ end
26
+
27
+ def hash?(it) = it.hash?
28
+ def dot?(it) = it.dot?
29
+ def eof?(it) = it.eof?
30
+ def space?(it) = it.space?
31
+
32
+ def forward(it) = it.next
33
+ def skip_space(it) = it.skip_space
34
+
35
+ def valid_attr_val?(it)
36
+ next_token = it.peek(1)
37
+ val = next_token.val
38
+ type = next_token.type
39
+
40
+ return false if %i[space eof].include?(type)
41
+
42
+ tmp = it.clone
43
+ tmp.next
44
+ return true if if_expr?(tmp) || for_expr?(tmp)
45
+
46
+ unless valid_value_token_for_shortcut?(type)
47
+ error("'#{val}' is not expected after '#{it.val}'", it)
48
+ end
49
+
50
+ true
51
+ end
52
+
53
+ def next_shortcut?(it)
54
+ _it = it.clone
55
+ _it.next while _it.space?
56
+
57
+ _it.hash? || _it.dot?
58
+ end
59
+
60
+ def read_id(it, attrs)
61
+ forward(it)
62
+ val = read_attr_val(it)
63
+ attrs[:id] = val unless val.nil?
64
+ end
65
+
66
+ def read_class(it, attrs)
67
+ forward(it)
68
+ val = read_attr_val(it)
69
+ attrs[:class] = attrs.fetch(:class, []).push(val) unless val.nil?
70
+ end
71
+
72
+ def read_attr_val(it)
73
+ parser.attr_value_reader.read(it, additional_delimiters: %i[dot hash slash lbrace rparen])
74
+ end
75
+ end
76
+ end
@@ -0,0 +1,50 @@
1
+ require_relative 'attr_read_helper'
2
+ require_relative 'parser_helper'
3
+
4
+ module Sade
5
+ class AttrValueReader
6
+ attr_reader :parser
7
+
8
+ include AttrReadHelper
9
+ include ParserHelper
10
+
11
+ def initialize(parser) = @parser = parser
12
+
13
+ def read(it, additional_delimiters: [])
14
+ error("attribute value not found", it) if it.rbrace? || it.eof?
15
+
16
+ if if_expr?(it)
17
+ node = parser.if_builder.build(it)
18
+ it.next
19
+ return node
20
+ end
21
+
22
+ if for_expr?(it)
23
+ node = parser.for_builder.build(it)
24
+ it.next
25
+ return node
26
+ end
27
+
28
+ if it.heredoc?
29
+ heredoc = parser.heredoc_builder.build(it)
30
+ it.next
31
+ return heredoc
32
+ end
33
+
34
+ delimiters = %i[space rbrace].concat(additional_delimiters)
35
+ composite =
36
+ parser.composite_builder.build(it, additional_delimiters: delimiters) do |_it|
37
+ if not valid_value_token?(_it.type)
38
+ error("#{_it.val} cannot be included as the attribute value", _it)
39
+ end
40
+ end
41
+
42
+ return nil if composite.nil?
43
+ return nil if composite.contents.empty?
44
+
45
+ return composite.contents.first if composite.contents.size == 1
46
+
47
+ composite
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,66 @@
1
+ require_relative 'node'
2
+ require_relative 'exception'
3
+ require_relative 'interpolation'
4
+ require_relative 'parser_helper'
5
+
6
+ module Sade
7
+ class CompositeBuilder
8
+ include ParserHelper
9
+ include Interpolation
10
+
11
+ def build(itr, has_parent: false, additional_delimiters: [], &validator)
12
+
13
+ str = String.new
14
+ buf = []
15
+ last_token_type = nil
16
+ delimiters = %i[eof lparen heredoc] + additional_delimiters
17
+
18
+ until delimiters.include?(itr.type)
19
+ break if itr.type == :rparen && has_parent
20
+
21
+ validator.call(itr) if block_given?
22
+
23
+ val = itr.val
24
+
25
+ if itr.variable?
26
+ buf.push(Text.new(str)) if str.size.positive?
27
+ buf.push(Variable.new(val[1..-1]))
28
+ str = String.new
29
+
30
+ elsif itr.text? && include_interpolation?(val)
31
+ # ここでいったんバッファ中の素のテキストを flush
32
+ buf.push(Text.new(str)) if str.size.positive?
33
+
34
+ # 補完入りテキストをフラットなノード列に分解して buf に展開
35
+ parse_interpolated_text(val, itr).each do |node|
36
+ buf << node
37
+ end
38
+ str = String.new
39
+
40
+ else
41
+ str << val unless last_token_type == :space && itr.space?
42
+ end
43
+
44
+ last_token_type = itr.type
45
+ itr.next
46
+ end
47
+
48
+ # 親タグ式から呼び出されて ) で閉じずにEOFの場合、例外を出す必要がある
49
+ eof_error(itr) if itr.eof? && has_parent
50
+
51
+ # 親タグ式から呼び出されて ) の前にスペーストークンがあるなら削除
52
+ tmp = if last_token_type == :space && itr.type == :rparen && has_parent
53
+ str.chop
54
+ else
55
+ str
56
+ end
57
+
58
+ buf.push(Text.new(tmp)) if tmp.size.positive?
59
+
60
+ return nil if buf.size.zero?
61
+
62
+ Composite.new(buf)
63
+ end
64
+ end
65
+ end
66
+
@@ -0,0 +1,23 @@
1
+ require_relative 'document_iterator'
2
+
3
+ module Sade
4
+ class Document
5
+ attr_reader :buff
6
+
7
+ def initialize(string)
8
+ @buff = string
9
+ end
10
+
11
+ def begin
12
+ DocumentIterator.new(buff, 0, 1, 1)
13
+ end
14
+
15
+ def substr(first, last)
16
+ buff[first.offset...last.offset]
17
+ end
18
+
19
+ def match?(first, last, str)
20
+ buff[first.offset...last.offset] == str
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,35 @@
1
+ require_relative 'iterator'
2
+
3
+ module Sade
4
+ class DocumentIterator < Iterator
5
+ attr_reader :line, :col
6
+
7
+ def initialize(buf, offset, line = 1, col = 1)
8
+ super(buf, offset)
9
+ @line = line
10
+ @col = col
11
+ end
12
+
13
+ def newline
14
+ self.next
15
+ @line += 1
16
+ @col = 1
17
+ end
18
+
19
+ def next
20
+ super
21
+ @col += 1
22
+ self
23
+ end
24
+
25
+ def step(count)
26
+ super
27
+ @col += count
28
+ self
29
+ end
30
+
31
+ def clone
32
+ self.class.new(state.buf, state.offset, line, col)
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,81 @@
1
+ require_relative 'node'
2
+ require_relative 'token'
3
+ require_relative 'parser_helper'
4
+
5
+ module Sade
6
+ class ElementBuilder
7
+ attr_reader :parser
8
+
9
+ include ParserHelper
10
+
11
+ def initialize(parser) = @parser = parser
12
+
13
+ def build(it)
14
+ last_it = it.matching_rparen
15
+
16
+ return nil unless it.lparen?
17
+
18
+ error("')' that should match '(' is not found", it) if last_it.nil?
19
+
20
+ it.next.skip_space
21
+
22
+ name = read_name(it)
23
+
24
+ return nil if %w[%if %else %for %include].include?(name)
25
+
26
+ it.next.skip_space
27
+
28
+ shortcut_attrs = parser.attr_shortcut_reader.read(it) || {}
29
+
30
+ it.skip_space unless shortcut_attrs.empty?
31
+
32
+ block_attrs = parser.attr_block_reader.read(it) || {}
33
+
34
+ attrs = merge_attrs(shortcut_attrs, block_attrs)
35
+
36
+ it.next.skip_space unless block_attrs.empty?
37
+
38
+ children = if it.slash?
39
+ it.next.skip_space
40
+ create_pseude_child(it, last_it)
41
+ else
42
+ parser.read_children(it, last_it)
43
+ end
44
+
45
+ Element.new(name, attrs, children)
46
+ end
47
+
48
+ def read_name(it)
49
+ error("unexpected character '#{it.val}'", it) unless it.symbol?
50
+ it.val
51
+ end
52
+
53
+ def merge_attrs(first, second)
54
+ return first if second.empty?
55
+ return second if first.empty?
56
+
57
+ second.each do |name, node|
58
+ if name == :class
59
+ first[:class] = first.fetch(:class, []).concat(node)
60
+ else
61
+ first[name] = node
62
+ end
63
+ end
64
+
65
+ first
66
+ end
67
+
68
+ def create_pseude_child(it, last_it)
69
+ sub_stream = TokenStream.from_iterators(it, last_it)
70
+ sub_stream.push_front(Token.new(type: :lparen, val: '(', line: -1, col: -1))
71
+ sub_stream.push_back(Token.new(type: :rparen, val: ')', line: -1, col: -1))
72
+
73
+ element = read_child_element(sub_stream.begin)
74
+ it.step(sub_stream.size - 2)
75
+
76
+ [element].compact
77
+ end
78
+
79
+ def read_child_element(it) = parser.element_builder.build(it)
80
+ end
81
+ end
@@ -0,0 +1,25 @@
1
+ require_relative 'parser_helper'
2
+
3
+ module Sade
4
+ class ElseBuilder
5
+ attr_reader :parser
6
+
7
+ include ParserHelper
8
+
9
+ def initialize(parser)
10
+ @parser = parser
11
+ end
12
+
13
+ def build(it)
14
+ last_it = it.matching_rparen
15
+
16
+ return nil unless else_expr?(it)
17
+ error("')' that should match '(' is not found", it) if last_it.nil?
18
+
19
+ it.next.skip_space # ( とその後ろのスペースをスキップ
20
+ it.next.skip_space # %else とその後ろのスペースをスキップ
21
+
22
+ Else.new(parser.read_children(it, last_it))
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,5 @@
1
+ module Sade
2
+ class LexerError < StandardError; end
3
+ class ParseError < StandardError; end
4
+ class RenderError < StandardError; end
5
+ end