hamster-web 0.1.0.pre.2

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 (60) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE.txt +21 -0
  3. data/README.md +61 -0
  4. data/exe/hamster +24 -0
  5. data/lib/hamster/build/manifest.rb +53 -0
  6. data/lib/hamster/build/postgres_compatibility.rb +38 -0
  7. data/lib/hamster/build/query_accessors.rb +73 -0
  8. data/lib/hamster/build/view_compiler.rb +239 -0
  9. data/lib/hamster/cli.rb +166 -0
  10. data/lib/hamster/console.rb +34 -0
  11. data/lib/hamster/data/persistence.rb +152 -0
  12. data/lib/hamster/data/query.rb +106 -0
  13. data/lib/hamster/data/read/compiler.rb +101 -0
  14. data/lib/hamster/data/read/decoder.rb +101 -0
  15. data/lib/hamster/data/read/nodes.rb +118 -0
  16. data/lib/hamster/data/read/relation.rb +198 -0
  17. data/lib/hamster/data/read/schema.rb +40 -0
  18. data/lib/hamster/data/read/validator.rb +273 -0
  19. data/lib/hamster/data/record.rb +81 -0
  20. data/lib/hamster/data/table.rb +90 -0
  21. data/lib/hamster/data.rb +21 -0
  22. data/lib/hamster/database/admin.rb +39 -0
  23. data/lib/hamster/database/configuration.rb +84 -0
  24. data/lib/hamster/database/connection.rb +168 -0
  25. data/lib/hamster/database/database.rb +92 -0
  26. data/lib/hamster/database/errors.rb +21 -0
  27. data/lib/hamster/database/pool.rb +98 -0
  28. data/lib/hamster/database/postgres.rb +66 -0
  29. data/lib/hamster/database/result.rb +30 -0
  30. data/lib/hamster/schema_validation.rb +12 -0
  31. data/lib/hamster/web/action/base.rb +39 -0
  32. data/lib/hamster/web/application.rb +23 -0
  33. data/lib/hamster/web/context.rb +25 -0
  34. data/lib/hamster/web/html.rb +80 -0
  35. data/lib/hamster/web/http.rb +34 -0
  36. data/lib/hamster/web/instrumentation.rb +28 -0
  37. data/lib/hamster/web/router.rb +27 -0
  38. data/lib/hamster/web/routing/base.rb +131 -0
  39. data/lib/hamster/web/version.rb +7 -0
  40. data/lib/hamster/web/view/form.rb +119 -0
  41. data/lib/hamster/web/view.rb +196 -0
  42. data/lib/hamster/web.rb +13 -0
  43. data/templates/app/.gitignore +5 -0
  44. data/templates/app/AGENTS.md +8 -0
  45. data/templates/app/Gemfile +3 -0
  46. data/templates/app/README.md +32 -0
  47. data/templates/app/app/routes/home/index/action.rb +14 -0
  48. data/templates/app/app/routes/home/index/view.html.erb +2 -0
  49. data/templates/app/app/routes/mappings.rb +8 -0
  50. data/templates/app/app/views/layouts/application.html.erb +3 -0
  51. data/templates/app/bin/hamster +4 -0
  52. data/templates/app/bin/server +8 -0
  53. data/templates/app/config/application.rb +23 -0
  54. data/templates/app/config/commands.rb +16 -0
  55. data/templates/app/config/database.rb +20 -0
  56. data/templates/app/config/hamster.json +1 -0
  57. data/templates/app/db/migrations/.keep +0 -0
  58. data/templates/app/spin.lock +6 -0
  59. data/templates/app/spin.toml +2 -0
  60. metadata +133 -0
@@ -0,0 +1,196 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hamster
4
+ module View
5
+ class Error < StandardError; end
6
+
7
+ def self.configure(site_name:, routes:)
8
+ @site_name = site_name
9
+ @routes = routes
10
+ end
11
+
12
+ def self.route_mapping
13
+ @routes
14
+ end
15
+
16
+ class Context
17
+ def initialize(name = nil, locals = nil)
18
+ @name = name
19
+ @locals = locals
20
+ @slots = {}
21
+ end
22
+
23
+ def fetch(*args)
24
+ locals, template, name, line = if args.length == 2
25
+ [@locals, @name, args[0], args[1]]
26
+ else
27
+ args
28
+ end
29
+ key = name.to_sym
30
+ return locals[key] if locals.key?(key)
31
+ return locals[name] if locals.key?(name)
32
+
33
+ raise Error, "#{template}:#{line}: missing local #{name}"
34
+ end
35
+
36
+ def content_for(name, fragment)
37
+ key = name.to_s
38
+ @slots[key] = HTML.join([@slots[key], fragment])
39
+ nil
40
+ end
41
+
42
+ def slot(name)
43
+ @slots[name.to_s] || HTML::Fragment.new("")
44
+ end
45
+ end
46
+
47
+ URL_ATTRIBUTES = %w[href src action formaction].freeze
48
+ TOKEN = /<%=(.*?)%>/m
49
+ NAME = /\A[a-z][a-z0-9_]*(?:\/[a-z][a-z0-9_]*)*\z/
50
+ LOCAL = /\A[a-z][a-z0-9_]*\z/
51
+
52
+ module_function
53
+
54
+ def render_compiled(name, locals = {}, layout: "layouts/application")
55
+ context = Context.new
56
+ body = CompiledViews.render(name, context, locals)
57
+ return body if layout.nil?
58
+
59
+ context.content_for("body", body)
60
+ title = context.slot("title")
61
+ site_name = @site_name || "Hamster"
62
+ full_title = title.to_s.empty? ? site_name : HTML.join([title, " · ", site_name])
63
+ CompiledViews.render(layout, context, title: full_title)
64
+ end
65
+
66
+ def append_value(output, value, name, line, position)
67
+ validate_url!(value, name, line) if URL_ATTRIBUTES.include?(position)
68
+ value = HTML.escape(value.to_s) if position.is_a?(String) && value.instance_of?(HTML::Fragment)
69
+ output << HTML.escape(value).to_s
70
+ end
71
+
72
+ def instrument_compiled(name)
73
+ kind = name.start_with?("layouts/") ? "layout" : name.include?("/shared/") ? "partial" : "template"
74
+ Hamster::Instrumentation.instrument("view.render", attributes: {"view.template" => name, "view.kind" => kind}) { yield }
75
+ end
76
+
77
+ def prepare!(sources)
78
+ @templates = compile(sources)
79
+ end
80
+
81
+ def use_compiled!
82
+ @templates = nil
83
+ end
84
+
85
+ def validate!(sources)
86
+ compile(sources)
87
+ true
88
+ end
89
+
90
+ def compile(sources)
91
+ prepared = {}
92
+ sources.each do |name, source|
93
+ raise Error, "invalid template name #{name}" unless name.match?(NAME)
94
+ instructions = []
95
+ cursor = 0
96
+ source.to_enum(:scan, TOKEN).each do
97
+ match = Regexp.last_match
98
+ prefix = source[0...match.begin(0)]
99
+ literal = source[cursor...match.begin(0)]
100
+ instructions << [:literal, literal.freeze] unless literal.empty?
101
+ line = prefix.count("\n") + 1
102
+ location = "#{name}:#{line}"
103
+ context = position(prefix.gsub(TOKEN, ""))
104
+ expression = match[1].strip
105
+ unless expression.match?(LOCAL)
106
+ raise Error, "#{location}: only <%= local %> is supported; compose partials in Ruby (got #{expression})"
107
+ end
108
+ raise Error, "#{location}: dynamic script or style content" if context == :script
109
+ raise Error, "#{location}: substitution must be in text or a quoted attribute" if context == :tag
110
+ if context.is_a?(String)
111
+ raise Error, "#{location}: dynamic event or style attribute" if context.start_with?("on") || context == "style" || context == "srcdoc"
112
+ end
113
+ instructions << [:value, expression.freeze, context, line].freeze
114
+ cursor = match.end(0)
115
+ end
116
+ tail = source[cursor..-1]
117
+ instructions << [:literal, tail.freeze] unless tail.empty?
118
+ unsupported = source.index(/<%(?!\=)/)
119
+ if unsupported
120
+ invalid_line = source[0...unsupported].count("\n") + 1
121
+ raise Error, "#{name}:#{invalid_line}: unsupported ERB tag; only <%= local %> is supported"
122
+ end
123
+ if tail.include?("<%")
124
+ invalid_line = source[0...cursor].count("\n") + tail[0...tail.index("<%")].count("\n") + 1
125
+ raise Error, "#{name}:#{invalid_line}: unterminated or unsupported ERB tag"
126
+ end
127
+ prepared[name.freeze] = instructions.freeze
128
+ end
129
+ prepared.freeze
130
+ end
131
+
132
+ def position(prefix)
133
+ script_tag = prefix.scan(%r{</?(?:script|style)\b[^>]*>}i).last
134
+ return :script if script_tag && !script_tag.start_with?("</")
135
+
136
+ open = prefix.rindex("<")
137
+ close = prefix.rindex(">")
138
+ if open && (!close || open > close)
139
+ tag = prefix[open..-1]
140
+ match = tag.match(/([a-zA-Z][a-zA-Z0-9:-]*)\s*=\s*(["'])[^"']*\z/)
141
+ return match[1].downcase if match
142
+ return :tag
143
+ end
144
+ :text
145
+ end
146
+
147
+ def render(name, locals = {}, kind = nil, **options)
148
+ if RUBY_ENGINE == "ruby"
149
+ return render_interpreted(name, locals, kind, **options) unless @templates.nil?
150
+ end
151
+ layout = options.delete(:layout) { "layouts/application" }
152
+ render_compiled(name, locals.merge(options), layout: layout)
153
+ end
154
+
155
+ def render_interpreted(name, locals = {}, kind = nil, **options)
156
+ if @templates.nil? && defined?(Hamster::CompiledViews)
157
+ layout = options.delete(:layout) { "layouts/application" }
158
+ locals = locals.merge(options)
159
+ return render_compiled(name, locals, layout: layout)
160
+ end
161
+ locals = locals.merge(options) unless options.empty?
162
+ instructions = @templates && @templates[name]
163
+ raise Error, "missing template #{name}" unless instructions
164
+
165
+ work = proc do
166
+ context = Context.new(name, locals)
167
+ output = instructions.map do |instruction|
168
+ case instruction[0]
169
+ when :literal then instruction[1]
170
+ when :value
171
+ value = context.fetch(instruction[1], instruction[3])
172
+ validate_url!(value, name, instruction[3]) if URL_ATTRIBUTES.include?(instruction[2])
173
+ if instruction[2].is_a?(String) && value.instance_of?(HTML::Fragment)
174
+ HTML.escape(value.to_s).to_s
175
+ else
176
+ HTML.escape(value).to_s
177
+ end
178
+ end
179
+ end.join
180
+ HTML::Fragment.new(output)
181
+ end
182
+ kind ||= name.start_with?("layouts/") ? "layout" : "template"
183
+ Hamster::Instrumentation.instrument("view.render", attributes: {"view.template" => name, "view.kind" => kind}) { work.call }
184
+ end
185
+
186
+ def validate_url!(value, name, line)
187
+ url = value.to_s
188
+ raise Error, "#{name}:#{line}: unsafe URL" unless url.start_with?("/") && !url.start_with?("//") && !url.match?(/[\x00-\x20\\<>"']/)
189
+ end
190
+
191
+ def path(name, params = {}, query: {}, **keywords)
192
+ raise Error, "route mapping is not configured" unless @routes
193
+ @routes.path(name, params, query: query, **keywords)
194
+ end
195
+ end
196
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "web/version"
4
+ require_relative "web/instrumentation"
5
+ require_relative "web/routing/base"
6
+ require_relative "web/view"
7
+ require_relative "web/html"
8
+ require_relative "web/view/form"
9
+ require_relative "web/http"
10
+ require_relative "web/action/base"
11
+ require_relative "web/context"
12
+ require_relative "web/router"
13
+ require_relative "web/application"
@@ -0,0 +1,5 @@
1
+ /.bundle/
2
+ /bin/*-server
3
+ /bin/*.tmp
4
+ /tmp/
5
+ /vendor/
@@ -0,0 +1,8 @@
1
+ # Application conventions
2
+
3
+ - Keep one action per endpoint directory with a sibling view.html.erb.
4
+ - Declare routes in app/routes/mappings.rb.
5
+ - App Ruby files load through config/manifest.rb; do not add local requires.
6
+ - Run bin/hamster manifest after editing application Ruby files or templates.
7
+ - Run bin/hamster manifest:check before building.
8
+ - Keep build-time code out of the Spinel runtime require chain.
@@ -0,0 +1,3 @@
1
+ source "https://rubygems.org"
2
+ gem "hamster-web", "= __HAMSTER_VERSION__", require: "hamster/web"
3
+ gem "spinel-spinner", "= 0.1.0.pre.1", require: "spinner"
@@ -0,0 +1,32 @@
1
+ # __APP_NAME__
2
+
3
+ Run `bin/hamster setup` to install gems and vendor the locked Spin PostgreSQL driver.
4
+ Spin and Spinel must already be installed. Configure DATABASE_USERNAME,
5
+ DATABASE_PASSWORD, and optionally DATABASE_HOST and DATABASE_PORT for your local
6
+ PostgreSQL server. DATABASE_NAME defaults to __APP_NAME___development.
7
+
8
+ Setup and build apply three string-copy compatibility fixes to the pinned
9
+ pg client for Spinel. The fixes are repeatable after spin vendor and reject
10
+ an unknown driver revision; avoid editing vendored driver sources manually.
11
+
12
+ Run `bin/hamster db:create`, `bin/hamster db:migrate`, `bin/hamster build`,
13
+ then `bin/hamster serve`. Build does not require a database connection.
14
+ An empty migration directory is valid; db:migrate initializes migration tracking.
15
+ The server listens on http://127.0.0.1:4000 by default; configure HOST and PORT
16
+ to override it. During framework development, install the local hamster-web
17
+ gem before running setup.
18
+
19
+ Run a deployed binary from the application root or set HAMSTER_APP_ROOT.
20
+ Include db/migrations alongside the binary. PostgreSQL TLS is not supported
21
+ by the current driver integration.
22
+
23
+ Use `bin/hamster console` (alias `c`) for an IRB session with app, application,
24
+ and database variables and a connection scope for record writes and queries.
25
+ Use `bin/hamster db:validate` to check model table/column declarations against
26
+ PostgreSQL without applying migrations. These commands use config/commands.rb,
27
+ which is deliberately excluded from the compiled server's boot chain.
28
+
29
+ Each endpoint has an action.rb and optional view.html.erb in app/routes/.
30
+ Declare endpoints centrally in app/routes/mappings.rb. Run
31
+ `bin/hamster manifest` after changing Ruby sources or templates and commit
32
+ the generated manifest and view registry with your source changes.
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+ module __APP_MODULE__
3
+ module Routes
4
+ module Home
5
+ module Index
6
+ class Action < Hamster::Action::Base
7
+ def call
8
+ respond(view: "home/index")
9
+ end
10
+ end
11
+ end
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,2 @@
1
+ <h1>Welcome to __APP_NAME__</h1>
2
+ <p>Your Hamster application is running.</p>
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+ module __APP_MODULE__
3
+ module Routes
4
+ class Mappings < Hamster::Routing::Base
5
+ get "/", to: Home::Index::Action, as: :root
6
+ end
7
+ end
8
+ end
@@ -0,0 +1,3 @@
1
+ <%# locals: title %>
2
+ <!doctype html><html><head><meta charset="utf-8"><title><%= title %></title></head>
3
+ <body><main><%= yield %></main></body></html>
@@ -0,0 +1,4 @@
1
+ #!/usr/bin/env ruby
2
+ require "bundler/setup"
3
+ require "hamster/cli"
4
+ exit Hamster::CLI.run(ARGV)
@@ -0,0 +1,8 @@
1
+ require "spinner"
2
+ require_relative "../config/application"
3
+ application = __APP_MODULE__.build
4
+ begin
5
+ Spinner::Server.new(application, host: ENV.fetch("HOST", "127.0.0.1"), port: ENV.fetch("PORT", "4000").to_i).run
6
+ ensure
7
+ application.shutdown
8
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+ require "hamster/web"
3
+ require_relative "database"
4
+ require_relative "manifest"
5
+
6
+ module __APP_MODULE__
7
+ def self.build
8
+ Hamster::View.configure(site_name: "__APP_NAME__", routes: Routes::Mappings)
9
+ database = Hamster::Database.new(database_configuration, migrations_path: File.join(root, "db/migrations"))
10
+ begin
11
+ database.verify!
12
+ Hamster::Application.new(routes: Routes::Mappings, database: database)
13
+ rescue StandardError
14
+ database.shutdown
15
+ raise
16
+ end
17
+ end
18
+
19
+ def self.validate_queries!
20
+ end
21
+ end
22
+
23
+ Hamster::View.configure(site_name: "__APP_NAME__", routes: __APP_MODULE__::Routes::Mappings)
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+ # CRuby command adapter; keep it out of the compiled server require chain.
3
+ require_relative "application"
4
+
5
+ module Hamster
6
+ module ApplicationCommands
7
+ def self.build
8
+ __APP_MODULE__.build
9
+ end
10
+
11
+ def self.database
12
+ Hamster::Database.new(__APP_MODULE__.database_configuration,
13
+ migrations_path: File.join(__APP_MODULE__.root, "db/migrations"))
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ if RUBY_ENGINE == "ruby"
4
+ $LOAD_PATH.unshift(File.expand_path("../vendor/packages/pg-0.2.0", __dir__))
5
+ end
6
+ require "hamster/database/postgres"
7
+
8
+ module __APP_MODULE__
9
+ def self.root
10
+ if RUBY_ENGINE == "ruby"
11
+ File.expand_path("..", __dir__)
12
+ else
13
+ ENV.fetch("HAMSTER_APP_ROOT", Dir.pwd)
14
+ end
15
+ end
16
+
17
+ def self.database_configuration
18
+ Hamster::Database::Configuration.from_env(app_name: "__APP_NAME__")
19
+ end
20
+ end
@@ -0,0 +1 @@
1
+ {"name":"__APP_NAME__","namespace":"__APP_MODULE__"}
File without changes
@@ -0,0 +1,6 @@
1
+ # generated by spin lock -- diff me, don't edit me
2
+
3
+ [lock.pg]
4
+ version = "0.2.0"
5
+ git = "https://github.com/rubys/spinel-pg.git"
6
+ ref = "5ff0188b0e8dffc88337723411ac725ee2659ff7"
@@ -0,0 +1,2 @@
1
+ [dependencies]
2
+ pg = { git = "https://github.com/rubys/spinel-pg.git", ref = "5ff0188b0e8dffc88337723411ac725ee2659ff7" }
metadata ADDED
@@ -0,0 +1,133 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: hamster-web
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0.pre.2
5
+ platform: ruby
6
+ authors:
7
+ - Dillon Wyatt
8
+ bindir: exe
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: spinel-spinner
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - '='
17
+ - !ruby/object:Gem::Version
18
+ version: 0.1.0.pre.1
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - '='
24
+ - !ruby/object:Gem::Version
25
+ version: 0.1.0.pre.1
26
+ - !ruby/object:Gem::Dependency
27
+ name: irb
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - ">="
31
+ - !ruby/object:Gem::Version
32
+ version: '1.13'
33
+ - - "<"
34
+ - !ruby/object:Gem::Version
35
+ version: '2'
36
+ type: :runtime
37
+ prerelease: false
38
+ version_requirements: !ruby/object:Gem::Requirement
39
+ requirements:
40
+ - - ">="
41
+ - !ruby/object:Gem::Version
42
+ version: '1.13'
43
+ - - "<"
44
+ - !ruby/object:Gem::Version
45
+ version: '2'
46
+ description: Hamster provides a compiled Spinel web runtime and a CLI for generating
47
+ applications.
48
+ email:
49
+ - dostaglou@gmail.com
50
+ executables:
51
+ - hamster
52
+ extensions: []
53
+ extra_rdoc_files: []
54
+ files:
55
+ - LICENSE.txt
56
+ - README.md
57
+ - exe/hamster
58
+ - lib/hamster/build/manifest.rb
59
+ - lib/hamster/build/postgres_compatibility.rb
60
+ - lib/hamster/build/query_accessors.rb
61
+ - lib/hamster/build/view_compiler.rb
62
+ - lib/hamster/cli.rb
63
+ - lib/hamster/console.rb
64
+ - lib/hamster/data.rb
65
+ - lib/hamster/data/persistence.rb
66
+ - lib/hamster/data/query.rb
67
+ - lib/hamster/data/read/compiler.rb
68
+ - lib/hamster/data/read/decoder.rb
69
+ - lib/hamster/data/read/nodes.rb
70
+ - lib/hamster/data/read/relation.rb
71
+ - lib/hamster/data/read/schema.rb
72
+ - lib/hamster/data/read/validator.rb
73
+ - lib/hamster/data/record.rb
74
+ - lib/hamster/data/table.rb
75
+ - lib/hamster/database/admin.rb
76
+ - lib/hamster/database/configuration.rb
77
+ - lib/hamster/database/connection.rb
78
+ - lib/hamster/database/database.rb
79
+ - lib/hamster/database/errors.rb
80
+ - lib/hamster/database/pool.rb
81
+ - lib/hamster/database/postgres.rb
82
+ - lib/hamster/database/result.rb
83
+ - lib/hamster/schema_validation.rb
84
+ - lib/hamster/web.rb
85
+ - lib/hamster/web/action/base.rb
86
+ - lib/hamster/web/application.rb
87
+ - lib/hamster/web/context.rb
88
+ - lib/hamster/web/html.rb
89
+ - lib/hamster/web/http.rb
90
+ - lib/hamster/web/instrumentation.rb
91
+ - lib/hamster/web/router.rb
92
+ - lib/hamster/web/routing/base.rb
93
+ - lib/hamster/web/version.rb
94
+ - lib/hamster/web/view.rb
95
+ - lib/hamster/web/view/form.rb
96
+ - templates/app/.gitignore
97
+ - templates/app/AGENTS.md
98
+ - templates/app/Gemfile
99
+ - templates/app/README.md
100
+ - templates/app/app/routes/home/index/action.rb
101
+ - templates/app/app/routes/home/index/view.html.erb
102
+ - templates/app/app/routes/mappings.rb
103
+ - templates/app/app/views/layouts/application.html.erb
104
+ - templates/app/bin/hamster
105
+ - templates/app/bin/server
106
+ - templates/app/config/application.rb
107
+ - templates/app/config/commands.rb
108
+ - templates/app/config/database.rb
109
+ - templates/app/config/hamster.json
110
+ - templates/app/db/migrations/.keep
111
+ - templates/app/spin.lock
112
+ - templates/app/spin.toml
113
+ licenses:
114
+ - MIT
115
+ metadata: {}
116
+ rdoc_options: []
117
+ require_paths:
118
+ - lib
119
+ required_ruby_version: !ruby/object:Gem::Requirement
120
+ requirements:
121
+ - - ">="
122
+ - !ruby/object:Gem::Version
123
+ version: '3.3'
124
+ required_rubygems_version: !ruby/object:Gem::Requirement
125
+ requirements:
126
+ - - ">="
127
+ - !ruby/object:Gem::Version
128
+ version: '0'
129
+ requirements: []
130
+ rubygems_version: 4.0.23
131
+ specification_version: 4
132
+ summary: A Spinel web framework and application generator
133
+ test_files: []