quince 0.1.1 → 0.3.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 915108de103a8f53a56eec910ca301b090ad537b2b368f919750283fe7781ae4
4
- data.tar.gz: e0ce22c06ac12cc12bf901aac59443e61e587bdf870af52c97faa0fe38cd65fd
3
+ metadata.gz: 2189200f4bee6758d6a981e3f4ba7b0d8dc558d0e0e2bb66fd421c11b404f954
4
+ data.tar.gz: 4153ceb85c057cfe7c2e74852aee81b82dde74362aecec53647b485fea191b87
5
5
  SHA512:
6
- metadata.gz: a7bf3cd3af27f2523d6d2b57ad4bcb121478bfa72cf71ae459f3bafe2a2a5a5ff2826acc645e3e7da3479df7548d32560b572e0bf09fa5101a631e819feb5a03
7
- data.tar.gz: f063a5d900d22b5c66a9cbc674782e04873b02a51860bb0c74146fb854e34a53de74dc7200a365bb5bfa85f85caf5b9c0c81ead9c2990066139ebc6e5387f00d
6
+ metadata.gz: 923689df34d18c7357785812424acc62f38f89a3966c568bfd6e86f03c41eb1a9dd629eebdee4b1ae9321e42e1180d0be65af560613e31543496ebc312c40a30
7
+ data.tar.gz: 2624224d31f066ef9562333264e6f1cc00275bf72ab35942a1b0b3097864a14c89f04a6d4ebeee7ffb07ce240f22bde8f07939b61ee64a8019017ceceead23c9
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- quince (0.1.1)
4
+ quince (0.3.0)
5
5
  oj (~> 3.13)
6
6
  typed_struct (>= 0.1.4)
7
7
 
@@ -9,7 +9,7 @@ GEM
9
9
  remote: https://rubygems.org/
10
10
  specs:
11
11
  diff-lcs (1.4.4)
12
- oj (3.13.5)
12
+ oj (3.13.6)
13
13
  rake (13.0.6)
14
14
  rbs (1.6.2)
15
15
  rspec (3.10.0)
data/README.md CHANGED
@@ -1,15 +1,162 @@
1
1
  # Quince
2
2
 
3
- Welcome to your new gem! In this directory, you'll find the files you need to be able to package up your Ruby library into a gem. Put your Ruby code in the file `lib/quince`. To experiment with that code, run `bin/console` for an interactive prompt.
3
+ ### What is Quince?
4
4
 
5
- TODO: Delete this and the text above, and describe your gem
5
+ Quince is an opinionated framework for building dynamic yet fully server-rendered web apps, with little to no JavaScript.
6
+
7
+ ### Inspired by
8
+
9
+ React, Turbo, Hotwire amongst others
10
+
11
+ ### Current status
12
+
13
+ Proof of concept, but [working in production](https://quince-rb.herokuapp.com/), and with decent performance despite few optimisations at this stage
14
+
15
+ ### How it works
16
+
17
+ - Define some components and `expose` them at certain routes
18
+ - Define some interactions that can take place, which can change the state of the components, and are handled with ruby methods
19
+ - The front end will swap out the updated components with new HTML re-rendered by the back end
20
+
21
+ ## Minimal 'hello world' example
22
+
23
+ ```ruby
24
+ # app.rb
25
+ require "quince_sinatra"
26
+
27
+ class App < Quince::Component
28
+ def render
29
+ html(
30
+ head,
31
+ body("hello world")
32
+ )
33
+ end
34
+ end
35
+
36
+ expose App, at: "/"
37
+ ```
38
+
39
+ - Run it via
40
+ ```sh
41
+ ruby app.rb
42
+ ```
43
+
44
+ - Visit `localhost:4567/`!
45
+
46
+ ## More complex example
47
+
48
+ ```ruby
49
+ require 'quince_sinatra'
50
+
51
+ class App < Quince::Component
52
+ def render
53
+ Layout(title: "First app") {[
54
+ Counter()
55
+ ]}
56
+ end
57
+ end
58
+
59
+ class Layout < Quince::Component
60
+ Props(title: String)
61
+
62
+ def render
63
+ html(
64
+ head(
65
+ internal_scripts
66
+ ),
67
+ body(
68
+ h1(props.title),
69
+ children
70
+ )
71
+ )
72
+ end
73
+ end
74
+
75
+ class Counter < Quince::Component
76
+ State(val: Integer)
77
+
78
+ def initialize
79
+ @state = State.new(
80
+ val: params.fetch(:val, 0),
81
+ )
82
+ end
83
+
84
+ exposed def increment
85
+ state.val += 1
86
+ end
87
+
88
+ exposed def decrement
89
+ state.val -= 1
90
+ end
91
+
92
+ def render
93
+ div(
94
+ h2("count is #{state.val}"),
95
+ button(onclick: method(:increment)) { "++" },
96
+ button(onclick: method(:decrement)) { "--" }
97
+ )
98
+ end
99
+ end
100
+
101
+ expose App, at: "/"
102
+ ```
103
+
104
+ #### See https://github.com/johansenja/quince-demo and https://quince-rb.herokuapp.com/ for more
105
+
106
+ ## Why Quince?
107
+
108
+ ### Why not?
109
+
110
+ - You have pre-existing APIs which you want to integrate a front end with
111
+ - You want to share the back end API with a different service
112
+ - You want more offline functionality
113
+ - You need a super complex/custom front end
114
+
115
+ ### Why?
116
+
117
+ - Lightweight 🪶
118
+ - Very few dependencies
119
+ - Just a couple hundred lines of core logic
120
+ - Fewer than 30 lines (unminified) of JavaScript in the front end
121
+ - Plug and play into multiple back ends 🔌
122
+ - Components > templates 🧩
123
+ - Write html-like elements, but with strong typo resistence
124
+ - no special syntax or compilation required
125
+ - Shallow learning curve if you are already familiar with React 📈
126
+ - Just worry about your core business logic and how the app looks 🧪
127
+ - No need to worry about
128
+ - routes
129
+ - controllers
130
+ - front end -> back end communication/APIs/Data transfer
131
+ - front end -> back end code sharing
132
+ - Quince handles these for you
133
+ - No node_modules 📦
134
+ - No yarn/npm
135
+ - Minimise bundle size concerns
136
+ - Manage your dependencies just using bundler & rubygems
137
+ - Make use of other pre-built Quince components via rubygems
138
+ - Get full use of Ruby's rich and comprehensive standard library 💎
139
+ - Take advantage of Ruby's ability to wrap native libraries (eg. gems using C) ⚡️
140
+ - Fully server-rendered responses 📡
141
+ - Single source of truth for your app's code (no code-sharing needed)
142
+ - Better SEO out the box
143
+ - Know exactly what your user is seeing
144
+ - Tracking a user's activity on the front end has become a big deal, especially in heavily front-end driven apps/SPAs, in order to be able to see how a user is actually using the app (ie. to track how the state has been changing on the front end)
145
+ - This normally requires cookies and a premium third party service
146
+ - But if everything a user sees is generated server-side, it would be easy to reconstruct a user's journey and their state changes
6
147
 
7
148
  ## Installation
8
149
 
9
- Add this line to your application's Gemfile:
150
+ Quince itself is framework agnostic, so you should use an adaptor which plugs it into an existing framework for handling basic server needs
151
+
152
+ ### Install it via adapters
153
+
154
+ - [Sinatra](https://github.com/johansenja/quince_sinatra)
155
+
156
+ Pick one, and add it to your application's Gemfile, eg:
10
157
 
11
158
  ```ruby
12
- gem 'quince'
159
+ gem 'quince_sinatra'
13
160
  ```
14
161
 
15
162
  And then execute:
@@ -18,11 +165,30 @@ And then execute:
18
165
 
19
166
  Or install it yourself as:
20
167
 
21
- $ gem install quince
22
-
23
- ## Usage
24
-
25
- TODO: Write usage instructions here
168
+ $ gem install quince_sinatra
169
+
170
+
171
+ ## Usage notes
172
+
173
+ - All HTML tags are available via a method of the same name, eg. `div()`, `section()`, `span()` - **with the exception of `para` standing in for `p` to avoid clashes with Ruby's common `Kernel#p` method**
174
+ - All HTML attributes are available, and are the same as they would be in a regular html document, eg. `onclick` rather than `onClick` - **with the exception of a `Class`, `Max`, `Min`, `Method`** - which start with capital letters to avoid clashes with some internal methods.
175
+ - Type checking is available at runtime for a component's `State` and `Props`, and is done in accordance with [Typed Struct](https://github.com/johansenja/typed_struct)
176
+ - Children can be specified in one of two places, depending on what you would prefer:
177
+ - as a block argument, to maintain similar readability with real html elements, where attributes come first
178
+ ```ruby
179
+ div(id: :my_div, style: "color: red") { h1("Single child") }
180
+ div(id: "div2", style: "color: green") {[
181
+ h2("multiple"),
182
+ h3("children")
183
+ ]}
184
+ ```
185
+ - as positional arguments (for convenience and cleanliness when no props are passed)
186
+ ```ruby
187
+ div(
188
+ h1("hello world")
189
+ )
190
+ ```
191
+ - A component's `render` method should always return a single top level element, ie. if you wanted to return 2 elements you should wrap them in a `div`
26
192
 
27
193
  ## Development
28
194
 
@@ -32,7 +198,7 @@ To install this gem onto your local machine, run `bundle exec rake install`. To
32
198
 
33
199
  ## Contributing
34
200
 
35
- Bug reports and pull requests are welcome on GitHub at https://github.com/[USERNAME]/quince.
201
+ Bug reports and pull requests are welcome on GitHub at https://github.com/johansenja/quince.
36
202
 
37
203
  ## License
38
204
 
@@ -11,11 +11,12 @@ module Quince
11
11
  t = Quince::Types
12
12
  opt_string_sym = Rbs("#{t::OptionalString} | Symbol")
13
13
  opt_bool = t::OptionalBoolean
14
- opt_method = Rbs("Method | Quince::Types::Undefined")
14
+ opt_callback = Rbs("Quince::Callback::Interface | Quince::Types::Undefined")
15
15
  value = opt_string_sym # for now
16
16
 
17
17
  ATTRIBUTES_BY_ELEMENT = {
18
18
  "A" => {
19
+ # download: t::Any,
19
20
  href: opt_string_sym,
20
21
  hreflang: opt_string_sym,
21
22
  media: opt_string_sym,
@@ -336,7 +337,7 @@ module Quince
336
337
  "Area" => {
337
338
  alt: opt_string_sym,
338
339
  coords: opt_string_sym,
339
- download: t::Any,
340
+ # download: t::Any,
340
341
  href: opt_string_sym,
341
342
  hreflang: opt_string_sym,
342
343
  media: opt_string_sym,
@@ -467,8 +468,13 @@ module Quince
467
468
  }.freeze
468
469
 
469
470
  DOM_EVENTS = {
470
- onclick: opt_method,
471
- onsubmit: opt_method,
471
+ onclick: opt_callback,
472
+ onsubmit: opt_callback,
473
+ onblur: opt_callback,
474
+ onchange: opt_callback,
475
+ onsearch: opt_callback,
476
+ onkeyup: opt_callback,
477
+ onselect: opt_callback,
472
478
  }.freeze
473
479
  end
474
480
  end
@@ -0,0 +1,36 @@
1
+ module Quince
2
+ class Callback
3
+ module ComponentHelpers
4
+ protected
5
+
6
+ DEFAULT_CALLBACK_OPTIONS = {
7
+ prevent_default: false,
8
+ take_form_values: false,
9
+ # others could include:
10
+ # debounce: false,
11
+ }.freeze
12
+
13
+ def callback(method_name, **opts)
14
+ unless self.class.instance_variable_get(:@exposed_actions).member?(method_name)
15
+ raise "The action you called is not exposed"
16
+ end
17
+
18
+ opts = DEFAULT_CALLBACK_OPTIONS.merge opts
19
+
20
+ Callback.new(self, method_name, **opts)
21
+ end
22
+ end
23
+
24
+ module Interface
25
+ attr_reader :receiver, :method_name, :prevent_default, :take_form_values
26
+ end
27
+
28
+ include Interface
29
+
30
+ def initialize(receiver, method_name, prevent_default:, take_form_values:)
31
+ @receiver, @method_name = receiver, method_name
32
+ @prevent_default = prevent_default
33
+ @take_form_values = take_form_values
34
+ end
35
+ end
36
+ end
@@ -1,3 +1,5 @@
1
+ require_relative "callback"
2
+
1
3
  module Quince
2
4
  class Component
3
5
  class << self
@@ -29,13 +31,10 @@ module Quince
29
31
  verb: meth0d,
30
32
  route: route,
31
33
  ) do |params|
32
- instance = Quince::Serialiser.deserialise params[:component]
34
+ instance = Quince::Serialiser.deserialise(CGI.unescapeHTML(params[:component]))
35
+ Quince::Component.class_variable_set :@@params, params
33
36
  if @exposed_actions.member? action
34
- if instance.method(action).arity.zero?
35
- instance.send action
36
- else
37
- instance.send action, params[:params]
38
- end
37
+ instance.send action
39
38
  instance
40
39
  else
41
40
  raise "The action you called is not exposed"
@@ -45,12 +44,17 @@ module Quince
45
44
  route
46
45
  end
47
46
 
48
- def initial_state=(attrs)
49
- @initial_state ||= self::State.new(**attrs)
47
+ def create(*children, **props, &block_children)
48
+ allocate.tap do |instance|
49
+ id = SecureRandom.alphanumeric 6
50
+ instance.instance_variable_set :@__id, id
51
+ instance.instance_variable_set :@props, initialize_props(self, id, **props)
52
+ kids = block_children ? block_children.call : children
53
+ instance.instance_variable_set(:@children, kids)
54
+ instance.send :initialize
55
+ end
50
56
  end
51
57
 
52
- attr_reader :initial_state
53
-
54
58
  private
55
59
 
56
60
  def initialize_props(const, id, **props)
@@ -58,14 +62,12 @@ module Quince
58
62
  end
59
63
  end
60
64
 
61
- attr_reader :props, :state, :children
65
+ include Callback::ComponentHelpers
62
66
 
63
- def initialize(*children, **props, &block_children)
64
- @__id = SecureRandom.alphanumeric(6)
65
- @props = self.class.send :initialize_props, self.class, @__id, **props
66
- @state = self.class.initial_state
67
- @children = block_children || children
68
- end
67
+ # set default
68
+ @@params = {}
69
+
70
+ attr_reader :props, :state, :children
69
71
 
70
72
  def render
71
73
  raise "not implemented"
@@ -77,11 +79,15 @@ module Quince
77
79
  self.class.exposed route, meth0d: via
78
80
  end
79
81
 
82
+ def params
83
+ @@params
84
+ end
85
+
80
86
  private
81
87
 
82
88
  attr_reader :__id
83
89
 
84
- HTML_SELECTOR_ATTR = :"data-respid"
90
+ HTML_SELECTOR_ATTR = :"data-quid"
85
91
 
86
92
  def html_element_selector
87
93
  "[#{HTML_SELECTOR_ATTR}='#{__id}']".freeze
@@ -33,23 +33,22 @@ module Quince
33
33
  attrib = case value
34
34
  when String, Integer, Float, Symbol
35
35
  value.to_s
36
- when Method
37
- owner = value.owner
36
+ when Callback::Interface
38
37
  receiver = value.receiver
39
- name = value.name
38
+ owner = receiver.class.name
39
+ name = value.method_name
40
40
  selector = receiver.send :html_element_selector
41
41
  internal = Quince::Serialiser.serialise receiver
42
- payload = { component: internal }.to_json
43
- case key
44
- when :onclick
45
- CGI.escape_html(
46
- "callRemoteEndpoint(`/api/#{owner}/#{name}`,`#{payload}`,`#{selector}`)"
47
- )
48
- when :onsubmit
49
- CGI.escape_html(
50
- "const p = #{payload}; callRemoteEndpoint( `/api/#{owner}/#{name}`, JSON.stringify({...p, params: getFormValues(this)}), `#{selector}`); return false"
51
- )
52
- end
42
+ payload = { component: CGI.escapeHTML(internal) }.to_json
43
+ payload_var_name = "p"
44
+ stringify_payload = if value.take_form_values
45
+ "{...#{payload_var_name}, params: getFormValues(this)}"
46
+ else
47
+ payload_var_name
48
+ end
49
+ cb = %Q{const #{payload_var_name} = #{payload}; callRemoteEndpoint(`/api/#{owner}/#{name}`, JSON.stringify(#{stringify_payload}),`#{selector}`)}
50
+ cb += ";return false" if value.prevent_default
51
+ CGI.escape_html(cb)
53
52
  when true
54
53
  return key
55
54
  when false, nil, Quince::Types::Undefined
@@ -2,100 +2,11 @@ module Quince
2
2
  class Serialiser
3
3
  class << self
4
4
  def serialise(obj)
5
- val = case obj
6
- when Quince::Component
7
- {
8
- id: serialise(obj.send(:__id)),
9
- props: serialise(obj.props),
10
- state: serialise(obj.state),
11
- children: serialise(obj.children),
12
- html_element_selector: serialise(obj.send(:html_element_selector)),
13
- }
14
- when Array
15
- obj.map { |e| serialise e }
16
- when TypedStruct, Struct, OpenStruct, Hash
17
- result = obj.each_pair.each_with_object({}) do |(k, v), ob|
18
- case v
19
- when Undefined
20
- next
21
- else
22
- ob[k] = serialise(v)
23
- end
24
- end
25
- if result.empty? && !obj.is_a?(Hash)
26
- obj = nil
27
- nil
28
- else
29
- result
30
- end
31
- when Proc
32
- obj = obj.call # is there a more efficient way of doing this?
33
- serialise(obj)
34
- when String
35
- res = obj.gsub "
36
- ", "
37
- "
38
- res.gsub! ?", '\"'
39
- res
40
- else
41
- obj
42
- end
43
-
44
- { t: obj.class&.name, v: val }
5
+ Oj.dump(obj)
45
6
  end
46
7
 
47
8
  def deserialise(json)
48
- case json[:t]
49
- when "String", "Integer", "Float", "NilClass", "TrueClass", "FalseClass"
50
- json[:v]
51
- when "Symbol"
52
- json[:v].to_sym
53
- when "Array"
54
- json[:v].map { |e| deserialise e }
55
- when "Hash"
56
- transform_hash json[:v]
57
- when "OpenStruct"
58
- OpenStruct.new(**transform_hash(props))
59
- when nil
60
- nil
61
- else
62
- klass = Object.const_get(json[:t])
63
- if klass < TypedStruct
64
- transform_hash_for_struct(json[:v]) || {}
65
- elsif klass < Quince::Component
66
- instance = klass.allocate
67
- val = json[:v]
68
- id = deserialise val[:id]
69
-
70
- instance.instance_variable_set :@__id, id
71
- instance.instance_variable_set(
72
- :@props,
73
- klass.send(
74
- :initialize_props,
75
- klass,
76
- id,
77
- **(deserialise(val[:props]) || {}),
78
- ),
79
- )
80
- st = deserialise(val[:state])
81
- instance.instance_variable_set :@state, klass::State.new(**st) if st
82
- instance.instance_variable_set :@children, deserialise(val[:children])
83
- instance
84
- else
85
- klass = Object.const_get(json[:t])
86
- klass.new(deserialise(json[:v]))
87
- end
88
- end
89
- end
90
-
91
- private
92
-
93
- def transform_hash(hsh)
94
- hsh.transform_values! { |v| deserialise v }
95
- end
96
-
97
- def transform_hash_for_struct(hsh)
98
- hsh.to_h { |k, v| [k.to_sym, deserialise(v)] }
9
+ Oj.load(json)
99
10
  end
100
11
  end
101
12
  end
@@ -10,21 +10,23 @@ module Quince
10
10
  def middleware=(middleware)
11
11
  @middleware = middleware
12
12
  Object.define_method(:expose) do |component, at:|
13
- component = component.new if component.instance_of? Class
14
-
15
13
  Quince.middleware.create_route_handler(
16
14
  verb: :GET,
17
15
  route: at,
18
- component: component,
19
- )
16
+ ) do |params|
17
+ component = component.create if component.instance_of? Class
18
+ Quince::Component.class_variable_set :@@params, params
19
+ component
20
+ end
20
21
  end
22
+ Object.send :private, :expose
21
23
  end
22
24
 
23
25
  def define_constructor(const, constructor_name = const.to_s)
24
26
  HtmlTagComponents.instance_eval do
25
27
  define_method(constructor_name) do |*children, **props, &block_children|
26
28
  new_props = { **props, Quince::Component::HTML_SELECTOR_ATTR => __id }
27
- const.new(*children, **new_props, &block_children)
29
+ const.create(*children, **new_props, &block_children)
28
30
  end
29
31
  end
30
32
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Quince
4
- VERSION = "0.1.1"
4
+ VERSION = "0.3.0"
5
5
  end
data/scripts.js CHANGED
@@ -18,7 +18,11 @@ const callRemoteEndpoint = (endpoint, payload, selector) => {
18
18
  })
19
19
  }
20
20
 
21
- const getFormValues = (form) => {
21
+ const getFormValues = (elem) => {
22
+ let form = elem.localName === "form" ? elem : elem.form;
23
+ if (!form) {
24
+ throw `element ${elem} should belong to a form`;
25
+ }
22
26
  const fd = new FormData(form);
23
27
  return Object.fromEntries(fd.entries());
24
28
  }
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: quince
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.1
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Joseph Johansen
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2021-09-09 00:00:00.000000000 Z
11
+ date: 2021-09-15 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: typed_struct
@@ -57,6 +57,7 @@ files:
57
57
  - bin/setup
58
58
  - lib/quince.rb
59
59
  - lib/quince/attributes_by_element.rb
60
+ - lib/quince/callback.rb
60
61
  - lib/quince/component.rb
61
62
  - lib/quince/html_tag_components.rb
62
63
  - lib/quince/serialiser.rb