quince 0.1.1 → 0.3.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 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