stipa 0.1.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.
@@ -0,0 +1,234 @@
1
+ require 'erb'
2
+ require 'json'
3
+
4
+ module Stipa
5
+ # ERB template engine with layout support and Vue.js integration helpers.
6
+ #
7
+ # Usage:
8
+ # engine = Stipa::Template.new(views_dir: 'views')
9
+ # html = engine.render('home', locals: { user: 'Alice' })
10
+ #
11
+ # With an explicit layout:
12
+ # html = engine.render('home', layout: 'layouts/admin')
13
+ #
14
+ # Without a layout (useful for partials / API fragments):
15
+ # html = engine.render('_row', locals: { item: obj }, layout: false)
16
+ #
17
+ # Directory conventions:
18
+ # views/
19
+ # layouts/
20
+ # application.html.erb ← default layout
21
+ # home.html.erb
22
+ # users/
23
+ # show.html.erb
24
+ # _sidebar.html.erb ← partials start with _
25
+ class Template
26
+ attr_reader :views_dir
27
+
28
+ def initialize(views_dir:)
29
+ @views_dir = File.expand_path(views_dir)
30
+ end
31
+
32
+ # Render a template, optionally wrapped in a layout.
33
+ #
34
+ # template - name like 'home', 'users/show', or 'home.html.erb'
35
+ # locals - Hash of variables made available inside the template
36
+ # layout - :default → auto-detect views/layouts/application.html.erb
37
+ # a String → explicit layout name (same resolution as template)
38
+ # false → no layout
39
+ def render(template, locals: {}, layout: :default)
40
+ ctx = ViewContext.new(self)
41
+ locals.each { |k, v| ctx._set_local(k, v) }
42
+
43
+ content = render_file(resolve(template), ctx)
44
+
45
+ layout_path = resolve_layout(layout)
46
+ if layout_path && File.exist?(layout_path)
47
+ ctx._set_content(content)
48
+ render_file(layout_path, ctx)
49
+ else
50
+ content
51
+ end
52
+ end
53
+
54
+ private
55
+
56
+ def render_file(path, ctx)
57
+ template = ERB.new(File.read(path), trim_mode: '-')
58
+ template.result(ctx._binding)
59
+ rescue Errno::ENOENT => e
60
+ raise "Template not found: #{path}"
61
+ end
62
+
63
+ def resolve(name)
64
+ # If it already ends with .erb, use it as-is; otherwise add .html.erb
65
+ name = "#{name}.html.erb" unless name.end_with?('.erb')
66
+ File.join(@views_dir, name)
67
+ end
68
+
69
+ def resolve_layout(layout)
70
+ return nil if layout == false
71
+ name = layout == :default ? 'layouts/application.html.erb' : "#{layout}.html.erb"
72
+ File.join(@views_dir, name)
73
+ end
74
+ end
75
+
76
+ # -------------------------------------------------------------------------
77
+ # View Context: the binding for ERB templates
78
+ # -------------------------------------------------------------------------
79
+
80
+ # The context object that becomes `self` inside every template.
81
+ # Includes helpers for rendering, HTML escaping, and Vue integration.
82
+ class ViewContext
83
+ def initialize(engine)
84
+ @engine = engine
85
+ @_blocks = {}
86
+ end
87
+
88
+ # -------------------------------------------------------------------------
89
+ # General helpers
90
+ # -------------------------------------------------------------------------
91
+
92
+ # Render a template (same engine, layout: false by default).
93
+ # Useful for partials; pass layout: :default if you want to wrap it.
94
+ def render_template(name, locals: {})
95
+ @engine.render(name, locals: locals, layout: false)
96
+ end
97
+
98
+ # HTML-escape a value. Use this in attributes or when interpolating
99
+ # untrusted user input inside text content.
100
+ #
101
+ # <div class="<%= escape_html(user.css_class) %>">
102
+ #
103
+ # Or use the alias:
104
+ #
105
+ # <div class="<%= h(user.css_class) %>">
106
+ def h(value)
107
+ ERB::Util.html_escape(value.to_s)
108
+ end
109
+ alias escape_html h
110
+
111
+ # -------------------------------------------------------------------------
112
+ # Vue.js helpers
113
+ # -------------------------------------------------------------------------
114
+
115
+ # Emit a Vue 3 component mount point.
116
+ #
117
+ # The Stīpa Vue bootstrapper (stipa-vue.js) picks up all elements with
118
+ # data-vue-component and mounts the corresponding registered component,
119
+ # passing data-props as the component's initial props.
120
+ #
121
+ # ERB:
122
+ # <%= vue_component("Counter", props: { initial: 5 }) %>
123
+ # <%= vue_component("SearchBox", props: { q: params[:q] }, class: "mt-4") %>
124
+ #
125
+ # Rendered HTML:
126
+ # <div data-vue-component="Counter" data-props="{&quot;initial&quot;:5}"></div>
127
+ #
128
+ # Options:
129
+ # props: Hash passed as JSON to the component (default {})
130
+ # tag: HTML wrapper element (default 'div')
131
+ # Any other keyword args become HTML attributes on the wrapper element.
132
+ def vue_component(name, props: {}, tag: 'div', **html_attrs)
133
+ attr_parts = html_attrs.map { |k, v| %(#{k}="#{h(v)}") }
134
+ attr_str = attr_parts.empty? ? '' : " #{attr_parts.join(' ')}"
135
+ props_json = h(props.to_json)
136
+ %(<#{tag} data-vue-component="#{h(name)}" data-props="#{props_json}"#{attr_str}></#{tag}>)
137
+ end
138
+
139
+ # Include Vue 3 from a CDN (or a local path you serve).
140
+ #
141
+ # <%= vue_script %> → unpkg, production build
142
+ # <%= vue_script(version: '3.4.21') %> → pin a specific version
143
+ # <%= vue_script(dev: true) %> → development build (warnings)
144
+ # <%= vue_script(src: '/vendor/vue.js') %> → self-hosted
145
+ def vue_script(src: nil, version: '3', dev: false)
146
+ unless src
147
+ build = dev ? 'vue.global.js' : 'vue.global.prod.js'
148
+ src = "https://unpkg.com/vue@#{version}/dist/#{build}"
149
+ end
150
+ %(<script src="#{src}"></script>)
151
+ end
152
+
153
+ # Include the Stīpa Vue bootstrapper.
154
+ # This script auto-discovers vue_component mount points and mounts them.
155
+ # Must appear AFTER vue_script and AFTER any component registrations.
156
+ #
157
+ # <%= stipa_vue_bootstrap %>
158
+ def stipa_vue_bootstrap(src: '/stipa-vue.js')
159
+ %(<script src="#{src}"></script>)
160
+ end
161
+
162
+ # Include one or more JavaScript files.
163
+ # <%= javascript_tag '/app.js' %>
164
+ # <%= javascript_tag '/a.js', '/b.js', type: 'module' %>
165
+ def javascript_tag(*srcs, type: nil, **attrs)
166
+ extra = attrs.map { |k, v| %( #{k}="#{h(v)}") }.join
167
+ type_attr = type ? %( type="#{h(type)}") : ''
168
+ srcs.map { |src| %(<script src="#{h(src)}"#{type_attr}#{extra}></script>) }.join("\n")
169
+ end
170
+
171
+ # Include one or more stylesheets.
172
+ # <%= stylesheet_tag '/app.css' %>
173
+ # <%= stylesheet_tag '/reset.css', '/app.css' %>
174
+ def stylesheet_tag(*hrefs, **attrs)
175
+ extra = attrs.map { |k, v| %( #{k}="#{h(v)}") }.join
176
+ hrefs.map { |href| %(<link rel="stylesheet" href="#{h(href)}"#{extra}>) }.join("\n")
177
+ end
178
+
179
+ # -------------------------------------------------------------------------
180
+ # Private: template/layout machinery
181
+ # -------------------------------------------------------------------------
182
+
183
+ # Internal: inject a local variable as a method on this context.
184
+ def _set_local(name, value)
185
+ define_singleton_method(name) { value }
186
+ end
187
+
188
+ # Internal: store the inner-page HTML for the layout's `yield`.
189
+ def _set_content(html)
190
+ @_layout_content = html
191
+ end
192
+
193
+ # Internal: expose binding for ERB.
194
+ def _binding
195
+ binding
196
+ end
197
+
198
+ # Called inside a layout template to render the inner page content.
199
+ #
200
+ # Use one of these in your layout:
201
+ # <body><%= content %></body>
202
+ # <body><%= yield_content %></body>
203
+ #
204
+ # (Plain ERB `yield` is a Ruby keyword and cannot be used here.)
205
+ def content
206
+ @_layout_content
207
+ end
208
+ alias yield_content content
209
+
210
+ # Named content blocks — store content from a page, render in the layout.
211
+ #
212
+ # Page: <% content_for :title do %>Home<% end %>
213
+ # Layout: <title><%= content_for(:title) %></title>
214
+ def content_for(section = nil, &block)
215
+ return @_blocks[section] if section && !block
216
+ @_blocks[section] = block.call if section && block
217
+ end
218
+
219
+ # Render a partial from within a template.
220
+ # Partials follow the Rails convention of a leading underscore on disk,
221
+ # but you refer to them without it.
222
+ #
223
+ # <%= render 'sidebar' %> → views/_sidebar.html.erb
224
+ # <%= render 'users/row', locals: { u: user } %> → views/users/_row.html.erb
225
+ def render(partial, locals: {})
226
+ name = if partial.include?('/')
227
+ partial.sub(%r{([^/]+)\z}, '_\1')
228
+ else
229
+ "_#{partial}"
230
+ end
231
+ @engine.render(name, locals: locals, layout: false)
232
+ end
233
+ end
234
+ end
@@ -0,0 +1,98 @@
1
+ require 'thread'
2
+
3
+ module Stipa
4
+ # Bounded thread pool with a fixed-depth work queue.
5
+ #
6
+ # Design:
7
+ # - SizedQueue (stdlib) handles all mutex/condvar complexity.
8
+ # - Workers loop forever, pulling callables off the queue.
9
+ # - Shutdown sends one :stop sentinel per worker (poison-pill pattern)
10
+ # so every thread unblocks from its blocking `pop` and exits cleanly.
11
+ # - submit returns true/false (never blocks the caller by default).
12
+ #
13
+ # Capacity math:
14
+ # pool_size=32, queue_depth=64, avg handler=10ms
15
+ # → steady-state concurrency at 1k req/s = 10 threads (31% utilization)
16
+ # → 64-slot queue absorbs ~64ms burst before 503s begin
17
+ class ThreadPool
18
+ def initialize(size: 16, queue_depth: nil, on_error: nil)
19
+ @size = size
20
+ @queue_depth = queue_depth || size * 4
21
+ @on_error = on_error || method(:default_error_handler)
22
+ @queue = SizedQueue.new(@queue_depth)
23
+ @workers = []
24
+ @shutdown = false
25
+ @mutex = Mutex.new
26
+ @size.times { @workers << spawn_worker }
27
+ end
28
+
29
+ # Submit a job (callable block) to the pool.
30
+ #
31
+ # mode: :drop — return false immediately if queue is full (default).
32
+ # :block — spin up to push_timeout seconds before giving up.
33
+ #
34
+ # Returns true if the job was accepted, false if dropped.
35
+ def submit(mode: :drop, push_timeout: 0.5, &job)
36
+ raise ArgumentError, 'job block required' unless job
37
+ return false if @shutdown
38
+
39
+ case mode
40
+ when :drop
41
+ # Non-blocking push. SizedQueue raises ThreadError when full.
42
+ begin
43
+ @queue.push(job, true) # true = non_block
44
+ true
45
+ rescue ThreadError
46
+ false
47
+ end
48
+ when :block
49
+ deadline = Time.now + push_timeout
50
+ loop do
51
+ begin
52
+ @queue.push(job, true)
53
+ return true
54
+ rescue ThreadError
55
+ return false if Time.now >= deadline
56
+ sleep 0.001 # 1ms spin — releases GVL while waiting
57
+ end
58
+ end
59
+ else
60
+ raise ArgumentError, "unknown mode: #{mode.inspect}"
61
+ end
62
+ end
63
+
64
+ # Graceful shutdown: stop accepting new jobs, drain the queue,
65
+ # then send a stop sentinel to every worker.
66
+ def shutdown(drain_timeout: 30.0)
67
+ @mutex.synchronize { @shutdown = true }
68
+ deadline = Time.now + drain_timeout
69
+ sleep 0.05 until @queue.empty? || Time.now >= deadline
70
+ # Poison-pill: one :stop per worker so each thread unblocks from pop
71
+ @size.times { @queue.push(:stop) }
72
+ @workers.each { |t| t.join(5) } # 5s hard join timeout per thread
73
+ end
74
+
75
+ def queue_depth; @queue.length; end
76
+
77
+ private
78
+
79
+ def spawn_worker
80
+ Thread.new do
81
+ loop do
82
+ job = @queue.pop # blocks until work is available or :stop arrives
83
+ break if job == :stop
84
+ begin
85
+ job.call
86
+ rescue => e
87
+ @on_error.call(e, job)
88
+ end
89
+ end
90
+ end
91
+ end
92
+
93
+ def default_error_handler(err, _job)
94
+ warn "[Stipa::ThreadPool] worker error: #{err.class}: #{err.message}"
95
+ err.backtrace&.first(3)&.each { |l| warn " #{l}" }
96
+ end
97
+ end
98
+ end
@@ -0,0 +1,3 @@
1
+ module Stipa
2
+ VERSION = "0.1.0"
3
+ end
data/lib/stipa.rb ADDED
@@ -0,0 +1,30 @@
1
+ # lib/stipa.rb — single require entry point
2
+ #
3
+ # Load order follows the dependency graph: leaf modules first,
4
+ # composite modules last. Adding `require 'stipa'` to a user's file
5
+ # loads the entire framework.
6
+ #
7
+ # Dependency order:
8
+ # version — no deps
9
+ # logger — no deps
10
+ # thread_pool — no deps
11
+ # middleware — no deps
12
+ # static — depends on middleware
13
+ # template — no deps (stdlib only: erb, json)
14
+ # request — no deps
15
+ # response — depends on template (via render helper)
16
+ # connection — depends on request, response
17
+ # server — depends on thread_pool, connection
18
+ # app — depends on server, middleware, template, request, response
19
+
20
+ require_relative 'stipa/version'
21
+ require_relative 'stipa/logger'
22
+ require_relative 'stipa/thread_pool'
23
+ require_relative 'stipa/middleware'
24
+ require_relative 'stipa/static'
25
+ require_relative 'stipa/template'
26
+ require_relative 'stipa/request'
27
+ require_relative 'stipa/response'
28
+ require_relative 'stipa/connection'
29
+ require_relative 'stipa/server'
30
+ require_relative 'stipa/app'
data/media/favicon.ico ADDED
Binary file
data/media/logo.png ADDED
Binary file
metadata ADDED
@@ -0,0 +1,149 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: stipa
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Pedro Harbs
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2026-03-18 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: minitest
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '5.20'
20
+ type: :development
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '5.20'
27
+ - !ruby/object:Gem::Dependency
28
+ name: rake
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '13.0'
34
+ type: :development
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '13.0'
41
+ - !ruby/object:Gem::Dependency
42
+ name: rubocop
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - "~>"
46
+ - !ruby/object:Gem::Version
47
+ version: '1.50'
48
+ type: :development
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - "~>"
53
+ - !ruby/object:Gem::Version
54
+ version: '1.50'
55
+ - !ruby/object:Gem::Dependency
56
+ name: yard
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - "~>"
60
+ - !ruby/object:Gem::Version
61
+ version: '0.9'
62
+ type: :development
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - "~>"
67
+ - !ruby/object:Gem::Version
68
+ version: '0.9'
69
+ description: |
70
+ Stīpa is a lightweight, zero-dependency HTTP/1.1 framework built entirely on Ruby stdlib.
71
+
72
+ Features:
73
+ - Pure stdlib (socket, thread, erb, json, securerandom)
74
+ - HTTP/1.1 with keep-alive, SO_REUSEPORT, and TCP_NODELAY
75
+ - Thread pool with bounded queue and graceful shutdown
76
+ - Pre-compiled middleware stack with zero per-request overhead
77
+ - ERB templates with layouts, partials, and Vue 3 island helpers
78
+ - CLI generator for MVC and API-only applications
79
+ - Structured logging in logfmt format
80
+ - Named route parameters via regex captures
81
+ email:
82
+ - harbspj@gmail.com
83
+ executables:
84
+ - stipa
85
+ extensions: []
86
+ extra_rdoc_files: []
87
+ files:
88
+ - CHANGELOG.md
89
+ - LICENSE
90
+ - README.md
91
+ - bin/stipa
92
+ - lib/js/stipa-vue.js
93
+ - lib/stipa.rb
94
+ - lib/stipa/app.rb
95
+ - lib/stipa/cli.rb
96
+ - lib/stipa/connection.rb
97
+ - lib/stipa/generator.rb
98
+ - lib/stipa/generators/api.rb
99
+ - lib/stipa/generators/base.rb
100
+ - lib/stipa/generators/vue.rb
101
+ - lib/stipa/logger.rb
102
+ - lib/stipa/middleware.rb
103
+ - lib/stipa/request.rb
104
+ - lib/stipa/response.rb
105
+ - lib/stipa/server.rb
106
+ - lib/stipa/static.rb
107
+ - lib/stipa/template.rb
108
+ - lib/stipa/thread_pool.rb
109
+ - lib/stipa/version.rb
110
+ - media/favicon.ico
111
+ - media/logo.png
112
+ homepage: https://github.com/pedroharbs/stipa
113
+ licenses:
114
+ - MIT
115
+ metadata:
116
+ bug_tracker_uri: https://github.com/pedroharbs/stipa/issues
117
+ changelog_uri: https://github.com/pedroharbs/stipa/releases
118
+ documentation_uri: https://github.com/pedroharbs/stipa
119
+ homepage_uri: https://github.com/pedroharbs/stipa
120
+ source_code_uri: https://github.com/pedroharbs/stipa
121
+ rubygems_mfa_required: 'true'
122
+ post_install_message: "╔══════════════════════════════════════════════════════════════════════════╗\n║
123
+ \ ║\n║ Welcome
124
+ to Stīpa! \U0001F680 ║\n║ ║\n║
125
+ \ Minimal, production-ready HTTP framework for Ruby with zero deps. ║\n║ ║\n║
126
+ \ Get started: ║\n║ $
127
+ stipa new my_app ║\n║ $ cd
128
+ my_app && bundle install && npm install ║\n║ $ bundle
129
+ exec ruby server.rb ║\n║ ║\n║
130
+ \ Documentation: https://github.com/pedroharbs/stipa ║\n║ ║\n╚══════════════════════════════════════════════════════════════════════════╝\n"
131
+ rdoc_options: []
132
+ require_paths:
133
+ - lib
134
+ required_ruby_version: !ruby/object:Gem::Requirement
135
+ requirements:
136
+ - - ">="
137
+ - !ruby/object:Gem::Version
138
+ version: '3.1'
139
+ required_rubygems_version: !ruby/object:Gem::Requirement
140
+ requirements:
141
+ - - ">="
142
+ - !ruby/object:Gem::Version
143
+ version: '0'
144
+ requirements: []
145
+ rubygems_version: 3.5.22
146
+ signing_key:
147
+ specification_version: 4
148
+ summary: Minimal, production-ready HTTP framework for Ruby
149
+ test_files: []