├── .gitignore ├── lib ├── romantic_text │ ├── version.rb │ ├── html_node.rb │ ├── utils.rb │ └── element.rb └── romantic_text.rb ├── bin ├── setup └── console ├── test ├── test_helper.rb ├── utils_test.rb └── romantic_text_test.rb ├── Gemfile ├── Rakefile ├── .rubocop.yml ├── Gemfile.lock ├── LICENSE.txt ├── romantic_text.gemspec └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | /.bundle/ 2 | /.yardoc 3 | /_yardoc/ 4 | /coverage/ 5 | /doc/ 6 | /pkg/ 7 | /spec/reports/ 8 | /tmp/ 9 | -------------------------------------------------------------------------------- /lib/romantic_text/version.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module RomanticText 4 | VERSION = '0.2.0' 5 | end 6 | -------------------------------------------------------------------------------- /bin/setup: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euo pipefail 3 | IFS=$'\n\t' 4 | set -vx 5 | 6 | bundle install 7 | 8 | # Do any other automated setup that you need to do here 9 | -------------------------------------------------------------------------------- /test/test_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'test/unit' 4 | require 'test/unit/rr' 5 | 6 | $LOAD_PATH.unshift File.expand_path('../lib', __dir__) 7 | require 'romantic_text' 8 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | source 'https://rubygems.org' 4 | 5 | git_source(:github) { |repo_name| "https://github.com/#{repo_name}" } 6 | 7 | # Specify your gem's dependencies in romantic_text.gemspec 8 | gemspec 9 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'bundler/gem_tasks' 4 | require 'rake/testtask' 5 | 6 | Rake::TestTask.new(:test) do |t| 7 | t.libs << 'test' 8 | t.libs << 'lib' 9 | t.test_files = FileList['test/**/*_test.rb'] 10 | end 11 | 12 | task default: :test 13 | -------------------------------------------------------------------------------- /lib/romantic_text/html_node.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'delegate' 4 | 5 | module RomanticText 6 | class HTMLNode < SimpleDelegator 7 | def initialize(text) 8 | super(text) 9 | @parent = nil 10 | end 11 | attr_accessor :parent 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /lib/romantic_text.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'romantic_text/version' 4 | require 'romantic_text/utils' 5 | require 'romantic_text/html_node' 6 | require 'romantic_text/element' 7 | 8 | module RomanticText 9 | class << self 10 | def markup(&block) 11 | Element.new.render(&block) 12 | end 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /lib/romantic_text/utils.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'cgi/escape' 4 | 5 | module RomanticText 6 | module Utils 7 | class << self 8 | def html_safe?(text) 9 | text.respond_to?(:html_safe?) ? text.html_safe? : false 10 | end 11 | 12 | def escape(text) 13 | html_safe?(text) ? text.to_s : CGI.escapeHTML(text.to_s) 14 | end 15 | end 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /bin/console: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | require 'bundler/setup' 5 | require 'romantic_text' 6 | 7 | # You can add fixtures and/or initialization code here to make experimenting 8 | # with your gem easier. You can also use a different console, if you like. 9 | 10 | # (If you use this, don't forget to add pry to your Gemfile!) 11 | # require "pry" 12 | # Pry.start 13 | 14 | require 'irb' 15 | IRB.start(__FILE__) 16 | -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | AllCops: 2 | Exclude: 3 | - tmp/**/* 4 | - vendor/**/* 5 | TargetRubyVersion: 2.5 6 | DisplayCopNames: true 7 | 8 | # no romantic :( 9 | Style/Alias: 10 | Enabled: false 11 | 12 | # no document 13 | Style/Documentation: 14 | Enabled: false 15 | 16 | # incompatible DSL 17 | Layout/EmptyLinesAroundArguments: 18 | Enabled: false 19 | 20 | # incompatible DSL 21 | Metrics/BlockLength: 22 | Enabled: false 23 | 24 | # incompatible DSL 25 | Metrics/ClassLength: 26 | Exclude: 27 | - test/**/* 28 | 29 | # my preference :) 30 | Metrics/LineLength: 31 | Max: 120 32 | 33 | # my preference :( 34 | Metrics/MethodLength: 35 | Max: 20 36 | 37 | # bye :) 38 | Naming/BinaryOperatorParameterName: 39 | Enabled: false 40 | Lint/MultipleCompare: 41 | Enabled: false 42 | -------------------------------------------------------------------------------- /Gemfile.lock: -------------------------------------------------------------------------------- 1 | PATH 2 | remote: . 3 | specs: 4 | romantic_text (0.2.0) 5 | 6 | GEM 7 | remote: https://rubygems.org/ 8 | specs: 9 | ast (2.4.0) 10 | jaro_winkler (1.5.3) 11 | parallel (1.17.0) 12 | parser (2.6.3.0) 13 | ast (~> 2.4.0) 14 | power_assert (1.1.4) 15 | rainbow (3.0.0) 16 | rake (10.5.0) 17 | rr (1.2.1) 18 | rubocop (0.72.0) 19 | jaro_winkler (~> 1.5.1) 20 | parallel (~> 1.10) 21 | parser (>= 2.6) 22 | rainbow (>= 2.2.2, < 4.0) 23 | ruby-progressbar (~> 1.7) 24 | unicode-display_width (>= 1.4.0, < 1.7) 25 | ruby-progressbar (1.10.1) 26 | test-unit (3.3.3) 27 | power_assert 28 | test-unit-rr (1.0.5) 29 | rr (>= 1.1.1) 30 | test-unit (>= 2.5.2) 31 | unicode-display_width (1.6.0) 32 | 33 | PLATFORMS 34 | ruby 35 | 36 | DEPENDENCIES 37 | bundler (~> 1.17) 38 | rake (~> 10.0) 39 | romantic_text! 40 | rubocop (= 0.72.0) 41 | test-unit (~> 3.3) 42 | test-unit-rr (~> 1.0) 43 | 44 | BUNDLED WITH 45 | 1.17.2 46 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2019 ru_shalm 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 13 | all 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 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /romantic_text.gemspec: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | lib = File.expand_path('lib', __dir__) 4 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) 5 | require 'romantic_text/version' 6 | 7 | Gem::Specification.new do |spec| 8 | spec.name = 'romantic_text' 9 | spec.version = RomanticText::VERSION 10 | spec.authors = ['ru_shalm'] 11 | spec.email = ['ru_shalm@hazimu.com'] 12 | 13 | spec.summary = 'A romantic DSL for writing HTML.' 14 | spec.homepage = 'https://github.com/rutan/romantic_text' 15 | spec.license = 'MIT' 16 | 17 | spec.files = Dir.chdir(File.expand_path(__dir__)) do 18 | `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) } 19 | end 20 | spec.bindir = 'exe' 21 | spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) } 22 | spec.require_paths = ['lib'] 23 | 24 | spec.required_ruby_version = '>= 2.5.0' 25 | 26 | spec.add_development_dependency 'bundler', '~> 1.17' 27 | spec.add_development_dependency 'rake', '~> 10.0' 28 | spec.add_development_dependency 'rubocop', '0.72.0' 29 | spec.add_development_dependency 'test-unit', '~> 3.3' 30 | spec.add_development_dependency 'test-unit-rr', '~> 1.0' 31 | end 32 | -------------------------------------------------------------------------------- /test/utils_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'test_helper' 4 | 5 | class UtilsTest < Test::Unit::TestCase 6 | sub_test_case '.html_safe?' do 7 | test 'normal string is false' do 8 | assert { RomanticText::Utils.html_safe?('text') == false } 9 | end 10 | 11 | test 'has html_safe? method' do 12 | str1 = Object.new 13 | mock(str1).html_safe? { true } 14 | assert { RomanticText::Utils.html_safe?(str1) == true } 15 | 16 | str2 = Object.new 17 | mock(str2).html_safe? { false } 18 | assert { RomanticText::Utils.html_safe?(str2) == false } 19 | end 20 | end 21 | 22 | sub_test_case '.escape' do 23 | test 'normal string' do 24 | assert { RomanticText::Utils.escape('text') == 'text' } 25 | assert { RomanticText::Utils.escape('' 101 | end 102 | end 103 | html.to_s # =>
<script>alert(1)</script>
104 | 105 | # danger :( 106 | html = RomanticText.markup do 107 | `div`[] do 108 | dangerous_raw_html '' 109 | end 110 | end 111 | html.to_s # =>
112 | ``` 113 | 114 | ### `>` syntax 115 | 116 | ```ruby 117 | html = RomanticText.markup do 118 | `table` > `tr` > `td`[class: 'red'] > 'item' 119 | end 120 | html.to_s # =>
item
121 | ``` 122 | 123 | ### Use `h` method 124 | 125 | ```ruby 126 | html1 = RomanticText.markup do 127 | `p`[class: 'red'] do 128 | `strong` << 'Hello' 129 | end 130 | end 131 | 132 | html2 = RomanticText.markup do 133 | h('p', class: 'red') do 134 | h('strong') << 'Hello' 135 | end 136 | end 137 | 138 | html1.to_s == html2.to_s # => true 139 | ``` 140 | 141 | ## Motivation 142 | 143 | I think backquote method is very very exciting :laughing: 144 | 145 | ```ruby 146 | class Hoge 147 | def `(arg) 148 | arg.upcase 149 | end 150 | 151 | def custom_eval(&block) 152 | instance_eval(&block) 153 | end 154 | end 155 | 156 | Hoge.new.custom_eval { `hello` } # => HELLO 157 | ``` 158 | 159 | It can be used like [Tagged templates in JavaScript](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Template_literals). 160 | 161 | ## Contributing 162 | 163 | Bug reports and pull requests are welcome on GitHub at https://github.com/rutan/romantic_text. 164 | 165 | ## License 166 | 167 | The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT). 168 | -------------------------------------------------------------------------------- /test/romantic_text_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'test_helper' 4 | 5 | class RomanticTextTest < Test::Unit::TestCase 6 | sub_test_case '#markup' do 7 | test 'simple' do 8 | html1 = RomanticText.markup do 9 | `div` 10 | end 11 | 12 | html2 = RomanticText.markup do 13 | h('div') 14 | end 15 | 16 | result = '
' 17 | 18 | assert { html1.to_s == result } 19 | assert { html2.to_s == result } 20 | end 21 | 22 | test 'multi items' do 23 | html1 = RomanticText.markup do 24 | `h1` 25 | `h2` 26 | `p` 27 | end 28 | 29 | html2 = RomanticText.markup do 30 | h('h1') 31 | h('h2') 32 | h('p') 33 | end 34 | 35 | result = '

' 36 | 37 | assert { html1.to_s == result } 38 | assert { html2.to_s == result } 39 | end 40 | 41 | test 'nesting' do 42 | html1 = RomanticText.markup do 43 | `div`[] do 44 | `p` << 'Hello, World' 45 | `p`[] do 46 | _ 'Good HTML!' 47 | end 48 | end 49 | end 50 | 51 | html2 = RomanticText.markup do 52 | h('div') do 53 | h('p') << 'Hello, World' 54 | h('p') do 55 | _ 'Good HTML!' 56 | end 57 | end 58 | end 59 | 60 | result = '

Hello, World

Good HTML!

' 61 | 62 | assert { html1.to_s == result } 63 | assert { html2.to_s == result } 64 | end 65 | 66 | test 'attributes' do 67 | html1 = RomanticText.markup do 68 | `ul`[class: 'item-list', 'data-items': [1, 2, 3]] do 69 | `li`[class: 'item'] << 1 70 | `li`[class: 'item'] << 2 71 | `li`[class: 'item'] << 3 72 | end 73 | end 74 | 75 | html2 = RomanticText.markup do 76 | h('ul', class: 'item-list', 'data-items': [1, 2, 3]) do 77 | h('li', class: 'item') << 1 78 | h('li', class: 'item') << 2 79 | h('li', class: 'item') << 3 80 | end 81 | end 82 | 83 | result = 84 | '' 87 | 88 | assert { html1.to_s == result } 89 | assert { html2.to_s == result } 90 | end 91 | 92 | test 'short hand id and class' do 93 | html1 = RomanticText.markup do 94 | `#wrapper.wrapper`[class: 'attr-class'] do 95 | `p.text` << 'attributes' 96 | `p.text#text`[id: 'attr-id'] << 'attributes' 97 | end 98 | end 99 | 100 | html2 = RomanticText.markup do 101 | h('#wrapper.wrapper', class: 'attr-class') do 102 | h('p.text') << 'attributes' 103 | h('p.text#text', id: 'attr-id') << 'attributes' 104 | end 105 | end 106 | 107 | result = 108 | '
' \ 109 | '

attributes

' \ 110 | '

attributes

' \ 111 | '
' 112 | assert { html1.to_s == result } 113 | assert { html2.to_s == result } 114 | end 115 | 116 | test 'escape text' do 117 | html1 = RomanticText.markup do 118 | `.article`['data-html': '

Hi!

'] do 119 | `a.title`[href: 'https://example.com?hoge=1'] << '' 120 | `.body`[] do 121 | dangerous_raw_html '

body

' 122 | end 123 | end 124 | end 125 | 126 | html2 = RomanticText.markup do 127 | h('.article', 'data-html': '

Hi!

') do 128 | h('a.title', href: 'https://example.com?hoge=1') << '' 129 | h('.body') do 130 | dangerous_raw_html '

body

' 131 | end 132 | end 133 | end 134 | 135 | result = 136 | '
' \ 137 | '<script>alert(1)</script>' \ 138 | '

body

' \ 139 | '
' 140 | assert { html1.to_s == result } 141 | assert { html2.to_s == result } 142 | end 143 | 144 | test 'use escaped text' do 145 | escaped_text = Object.new 146 | mock(escaped_text).html_safe? { true } 147 | mock(escaped_text).to_s { 'big' } 148 | 149 | html = RomanticText.markup do 150 | `span` << escaped_text 151 | end 152 | 153 | assert { html.to_s == 'big' } 154 | end 155 | 156 | test 'use > syntax' do 157 | html1 = RomanticText.markup do 158 | `div` > `p` > `span`[class: 'red'] > 'Hello, Syntax' 159 | end 160 | 161 | html2 = RomanticText.markup do 162 | h('div') > h('p') > h('span', class: 'red') > 'Hello, Syntax' 163 | end 164 | 165 | result = '

Hello, Syntax

' 166 | 167 | assert { html1.to_s == result } 168 | assert { html2.to_s == result } 169 | end 170 | end 171 | end 172 | --------------------------------------------------------------------------------