jimmu 1.0.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.
Files changed (94) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +21 -0
  3. data/README.md +279 -0
  4. data/examples/bbs/app.rb +21 -0
  5. data/examples/bbs/public/css/style.css +15 -0
  6. data/examples/bbs/views/404.erb +1 -0
  7. data/examples/bbs/views/500.erb +1 -0
  8. data/examples/bbs/views/index.erb +29 -0
  9. data/examples/bbs/views/layout.erb +15 -0
  10. data/examples/blog/app.rb +28 -0
  11. data/examples/blog/public/css/style.css +18 -0
  12. data/examples/blog/views/404.erb +1 -0
  13. data/examples/blog/views/500.erb +1 -0
  14. data/examples/blog/views/index.erb +9 -0
  15. data/examples/blog/views/layout.erb +15 -0
  16. data/examples/blog/views/new.erb +18 -0
  17. data/examples/blog/views/post/[id].erb +17 -0
  18. data/examples/chatroom/app.rb +44 -0
  19. data/examples/chatroom/public/css/style.css +15 -0
  20. data/examples/chatroom/views/404.erb +1 -0
  21. data/examples/chatroom/views/500.erb +1 -0
  22. data/examples/chatroom/views/index.erb +18 -0
  23. data/examples/chatroom/views/layout.erb +12 -0
  24. data/examples/chatroom/views/room/[name].erb +47 -0
  25. data/examples/counter/app.rb +15 -0
  26. data/examples/counter/public/css/style.css +8 -0
  27. data/examples/counter/public/js/app.js +11 -0
  28. data/examples/counter/views/404.erb +1 -0
  29. data/examples/counter/views/500.erb +1 -0
  30. data/examples/counter/views/api/increment.erb +5 -0
  31. data/examples/counter/views/api/reset.erb +4 -0
  32. data/examples/counter/views/index.erb +8 -0
  33. data/examples/counter/views/layout.erb +13 -0
  34. data/examples/hello-world/app.rb +6 -0
  35. data/examples/hello-world/public/css/style.css +3 -0
  36. data/examples/hello-world/views/404.erb +2 -0
  37. data/examples/hello-world/views/500.erb +1 -0
  38. data/examples/hello-world/views/greet/[name].erb +2 -0
  39. data/examples/hello-world/views/index.erb +3 -0
  40. data/examples/hello-world/views/layout.erb +12 -0
  41. data/examples/image-upload/app.rb +22 -0
  42. data/examples/image-upload/public/css/style.css +11 -0
  43. data/examples/image-upload/views/404.erb +1 -0
  44. data/examples/image-upload/views/500.erb +1 -0
  45. data/examples/image-upload/views/index.erb +31 -0
  46. data/examples/image-upload/views/layout.erb +15 -0
  47. data/examples/json-api/app.rb +27 -0
  48. data/examples/json-api/views/404.erb +2 -0
  49. data/examples/json-api/views/500.erb +2 -0
  50. data/examples/json-api/views/api/notes/[id].erb +33 -0
  51. data/examples/json-api/views/api/notes.erb +20 -0
  52. data/examples/json-api/views/index.erb +13 -0
  53. data/examples/login-demo/app.rb +31 -0
  54. data/examples/login-demo/public/css/style.css +12 -0
  55. data/examples/login-demo/views/404.erb +1 -0
  56. data/examples/login-demo/views/500.erb +1 -0
  57. data/examples/login-demo/views/index.erb +6 -0
  58. data/examples/login-demo/views/layout.erb +24 -0
  59. data/examples/login-demo/views/login.erb +22 -0
  60. data/examples/login-demo/views/logout.erb +4 -0
  61. data/examples/login-demo/views/mypage/index.erb +3 -0
  62. data/examples/login-demo/views/signup.erb +24 -0
  63. data/examples/poll/app.rb +55 -0
  64. data/examples/poll/public/css/style.css +10 -0
  65. data/examples/poll/public/js/app.js +24 -0
  66. data/examples/poll/views/404.erb +1 -0
  67. data/examples/poll/views/500.erb +1 -0
  68. data/examples/poll/views/index.erb +6 -0
  69. data/examples/poll/views/layout.erb +13 -0
  70. data/examples/todo/app.rb +21 -0
  71. data/examples/todo/public/css/style.css +17 -0
  72. data/examples/todo/views/404.erb +1 -0
  73. data/examples/todo/views/500.erb +1 -0
  74. data/examples/todo/views/delete/[id].erb +4 -0
  75. data/examples/todo/views/index.erb +27 -0
  76. data/examples/todo/views/layout.erb +15 -0
  77. data/examples/todo/views/toggle/[id].erb +5 -0
  78. data/examples/url-shortener/app.rb +39 -0
  79. data/examples/url-shortener/public/css/style.css +13 -0
  80. data/examples/url-shortener/views/404.erb +1 -0
  81. data/examples/url-shortener/views/500.erb +1 -0
  82. data/examples/url-shortener/views/[code].erb +10 -0
  83. data/examples/url-shortener/views/api/shorten.erb +13 -0
  84. data/examples/url-shortener/views/index.erb +35 -0
  85. data/examples/url-shortener/views/layout.erb +15 -0
  86. data/examples/visitor-counter/app.rb +22 -0
  87. data/examples/visitor-counter/public/css/style.css +4 -0
  88. data/examples/visitor-counter/views/404.erb +1 -0
  89. data/examples/visitor-counter/views/500.erb +1 -0
  90. data/examples/visitor-counter/views/index.erb +11 -0
  91. data/examples/visitor-counter/views/layout.erb +12 -0
  92. data/exe/jimmu +5 -0
  93. data/lib/jimmu.rb +1916 -0
  94. metadata +143 -0
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 6fd8e1e08377f2b4c7790010cc672bde1c412453fdcef70b726527ac38da488e
4
+ data.tar.gz: 386d566253e29f4812a6a1e6cea8e8e1b54c12bff8a13b805856879ee54b68b2
5
+ SHA512:
6
+ metadata.gz: 8c63c33170e4b00bc12a974e8f15381edf7a56e8b539b6dacc888afeb8de27e601dd8b2d15b27bc2e9464d344fed9a6db711b2c7e87009ff9f2859312df7cf5a
7
+ data.tar.gz: db14fb9673208ce1a80f49627bf214a014e56cd16f8adf39014019fbc52363ea1f112b2280ff1380dae15cac4c84b2dd311014276eda454289360d61ac9e2550
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Araki-Tomoya
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 all
13
+ 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 THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,279 @@
1
+ # Jimmu (神武)
2
+
3
+ A lightweight Ruby web framework. Full-featured but simple: ERB, SQLite,
4
+ file-based routing, and WebSockets in one file, so you can go from
5
+ nothing to a working small web app in minutes.
6
+
7
+ ```
8
+ gem install jimmu
9
+ jimmu new myapp
10
+ cd myapp
11
+ jimmu server
12
+ ```
13
+
14
+ Then visit `http://127.0.0.1:3000/`.
15
+
16
+ ## Why
17
+
18
+ - **One file.** The entire framework is `lib/jimmu.rb`. Nothing to configure,
19
+ nothing to generate boilerplate for.
20
+ - **No native gem dependencies.** SQLite support talks directly to your
21
+ system's `libsqlite3` via Ruby's built-in `Fiddle` library -- there's no
22
+ `sqlite3` gem, so there's no native extension to compile when you install.
23
+ Everything else is Ruby standard library.
24
+ - **A file is a page.** Drop a `.erb` file under `views/` and it's routable.
25
+ No router config, no controller classes.
26
+
27
+ ## Installing
28
+
29
+ ```
30
+ gem install jimmu
31
+ ```
32
+
33
+ Requires Ruby >= 2.7 and a system SQLite3 shared library, which ships with
34
+ macOS and is available via your package manager on Linux
35
+ (`apt install libsqlite3-0`, `dnf install sqlite-libs`, etc.) and typically
36
+ already present with Ruby installations on Windows.
37
+
38
+ ## The CLI
39
+
40
+ | Command | Effect |
41
+ |---|---|
42
+ | `jimmu new <name>` | Create a new project in `./<name>` |
43
+ | `jimmu server` | Start the dev server, reading `./app.rb` |
44
+ | `jimmu server -p 8080` | ...on a specific port (overrides `port` in app.rb) |
45
+ | `jimmu server -h 0.0.0.0` | ...on a specific host (overrides `host` in app.rb) |
46
+ | `jimmu version` | Print the installed version |
47
+ | `jimmu help` | Show usage |
48
+
49
+ ## Project layout
50
+
51
+ ```
52
+ myapp/
53
+ ├── app.rb # entry point -- config, hooks, then `run`
54
+ ├── views/
55
+ │ ├── layout.erb # auto-applied around every page unless disabled
56
+ │ ├── index.erb # -> /
57
+ │ ├── 404.erb # shown on 404
58
+ │ └── 500.erb # shown on unhandled errors
59
+ ├── public/
60
+ │ ├── css/style.css # -> /css/style.css
61
+ │ ├── js/app.js # -> /js/app.js
62
+ │ └── img/
63
+ └── db/ # SQLite files live here by convention
64
+ ```
65
+
66
+ ## File-based routing
67
+
68
+ A file's path under `views/` becomes its URL. `index.erb` files match their
69
+ parent directory:
70
+
71
+ ```
72
+ views/index.erb -> /
73
+ views/about.erb -> /about
74
+ views/blog/index.erb -> /blog
75
+ views/blog/post.erb -> /blog/post
76
+ ```
77
+
78
+ Square brackets make a segment dynamic and land in `params`:
79
+
80
+ ```
81
+ views/user/[id].erb -> /user/:id params[:id]
82
+ views/post/[category]/[id].erb -> /post/:category/:id params[:category], params[:id]
83
+ ```
84
+
85
+ A literal file always wins over a dynamic one at the same level (so
86
+ `views/user/admin.erb` would take priority over `views/user/[id].erb` for
87
+ `/user/admin` specifically, while every other value still hits the dynamic
88
+ route).
89
+
90
+ Every HTTP method routes through the same file -- check `request.method`
91
+ inside the template to branch between, say, showing a form on `GET` and
92
+ handling its submission on `POST` to the same path.
93
+
94
+ ## Inside a view
95
+
96
+ Each `.erb` file has access to:
97
+
98
+ ```ruby
99
+ params[:id] # route + query + form + JSON body params (merged)
100
+ request.method # "GET", "POST", ...
101
+ request.path # "/user/42"
102
+ request.ip # client IP
103
+ request.header("user-agent") # any request header, by lowercase name
104
+ request.query_params # just the ?query=string part
105
+ cookie[:theme] # read a cookie
106
+ cookie[:theme] = "dark" # queue a Set-Cookie
107
+ session[:user_id] # read/write server-side session data
108
+ # (survives across requests via a cookie;
109
+ # cleared with session.clear or session.delete(:k))
110
+
111
+ status 404 # set the response status (doesn't stop rendering)
112
+ header "X-Custom", "value" # set a response header
113
+
114
+ redirect "/login" # 302 and stop
115
+ json(ok: true) # render JSON and stop
116
+ text "plain text" # render text/plain and stop
117
+ html "<b>hi</b>" # render text/html and stop
118
+ render "other_view" # render another view (through the layout) and stop
119
+
120
+ layout false # skip views/layout.erb for this render
121
+
122
+ log "message" # timestamped line on the server console
123
+ db # the most recently opened `sqlite` connection
124
+ ```
125
+
126
+ `redirect` / `json` / `text` / `html` / `render` all end the current
127
+ template immediately -- nothing written after them in the same `.erb` file
128
+ runs.
129
+
130
+ ### Layouts
131
+
132
+ `views/layout.erb` wraps every page automatically; put `<%= yield %>` where
133
+ the page's own content should go. Turn it off per-page with `<% layout false %>`
134
+ anywhere in that page's template.
135
+
136
+ ### Errors
137
+
138
+ `views/404.erb` renders on an unmatched route; `views/500.erb` renders on an
139
+ unhandled exception (it can read `error.message` / `error.backtrace` if you
140
+ want to show details, or just show a friendly message). You can also trigger
141
+ either one yourself:
142
+
143
+ ```erb
144
+ <% unless post
145
+ status 404
146
+ render "404" # note: a string, not :404 -- see "Gotchas" below
147
+ end %>
148
+ ```
149
+
150
+ If neither file exists, Jimmu shows its own built-in page -- a plain message
151
+ for 404, and a full backtrace for 500 (since that's only ever seen when you
152
+ haven't set up your own error page, it defaults to maximally useful rather
153
+ than maximally polished).
154
+
155
+ ### SQLite
156
+
157
+ ```ruby
158
+ # in app.rb
159
+ db = sqlite "db/app.db"
160
+ db.exec("CREATE TABLE IF NOT EXISTS users (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT)")
161
+ ```
162
+
163
+ ```erb
164
+ <%# in any view %>
165
+ <% users = db.query("SELECT * FROM users WHERE name = ?", params[:name]) %>
166
+ <% db.exec("INSERT INTO users(name) VALUES (?)", params[:name]) %>
167
+ <% db.transaction do
168
+ db.exec("...")
169
+ db.exec("...")
170
+ end %>
171
+ ```
172
+
173
+ `query` returns an Array of Hashes with Symbol keys. `exec` returns the
174
+ number of rows changed. `db.last_insert_rowid` gives you the last
175
+ auto-increment id. `?` placeholders are the only supported binding style;
176
+ always use them for anything that includes user input.
177
+
178
+ ### File uploads
179
+
180
+ ```erb
181
+ <form method="post" enctype="multipart/form-data">
182
+ <input type="file" name="avatar">
183
+ </form>
184
+ ```
185
+ ```erb
186
+ <% file = params[:avatar]
187
+ file.filename # "photo.png"
188
+ file.content_type # "image/png"
189
+ file.size # bytes
190
+ file.save("public/uploads/photo.png") %>
191
+ ```
192
+
193
+ ### WebSockets
194
+
195
+ Registered in `app.rb`, separately from the file-based view routes:
196
+
197
+ ```ruby
198
+ websocket "/ws/[room]" do
199
+ log "opened: #{params[:room]}"
200
+ on_message { |msg| send("echo: #{msg}") }
201
+ on_close { log "closed" }
202
+ end
203
+ ```
204
+
205
+ Inside the block (and inside `on_message` / `on_close`), `self` is the
206
+ connection, so `send`, `close`, `params`, `session`, `cookie`, `request`,
207
+ `log`, and `db` are all available directly. There's no special "broadcast"
208
+ method -- see `examples/chatroom` and `examples/poll` for the few lines of
209
+ plain Ruby (a shared Array/Hash + Mutex) it takes to build one yourself.
210
+
211
+ ### Hooks
212
+
213
+ ```ruby
214
+ before do
215
+ log "#{request.method} #{request.path}"
216
+ end
217
+
218
+ after do
219
+ header "X-Powered-By", "Jimmu"
220
+ end
221
+ ```
222
+
223
+ `before` runs ahead of every request (including static files); calling
224
+ `redirect`/`json`/etc. inside one stops the request right there, which is
225
+ enough to build things like auth gates. `after` runs once a response body
226
+ has been produced.
227
+
228
+ ## Gotchas
229
+
230
+ - **`render "404"`, not `render :404`.** `:404` isn't valid Ruby (a bare
231
+ symbol can't start with a digit) -- use a String for view names that start
232
+ with a number.
233
+ - **Helpers belong at the true top level of app.rb.** `def my_helper` /
234
+ `MY_CONST = ...` / `module Helpers ... end` written directly in app.rb are
235
+ ordinary top-level Ruby and are callable from anywhere -- views,
236
+ `before`/`after`, `websocket` blocks. (Concretely: app.rb is `load`ed as a
237
+ normal file, not stringwise `eval`'d, specifically so this works.)
238
+ - **Sessions are in-memory.** They reset when the server restarts. Fine for
239
+ development and small apps; swap in a database-backed store yourself if
240
+ you need durability.
241
+ - **One request per connection.** Keep-alive isn't implemented -- each HTTP
242
+ request gets its own TCP connection, which keeps the server simple. This
243
+ doesn't apply to WebSocket connections, which stay open as long as the
244
+ client does.
245
+ - **No chunked request bodies.** Incoming requests are read via
246
+ `Content-Length`; chunked `Transfer-Encoding` on requests isn't parsed
247
+ (this practically never comes up for browser form posts or `fetch()`).
248
+
249
+ ## Examples
250
+
251
+ Twelve complete apps in `examples/`, each `jimmu server`-able as-is:
252
+
253
+ | Example | Shows off |
254
+ |---|---|
255
+ | `hello-world` | The absolute minimum, plus one dynamic route |
256
+ | `bbs` | A message board -- SQLite, forms, session-remembered name |
257
+ | `chatroom` | Named chat rooms over WebSocket, with a hand-rolled broadcast |
258
+ | `todo` | SQLite CRUD via small per-action routes |
259
+ | `blog` | Posts list/detail, `/post/[id]`, a create form, custom 404 |
260
+ | `counter` | A JSON API endpoint called from client-side `fetch()` |
261
+ | `url-shortener` | Dynamic redirect route, plus a JSON API to create links |
262
+ | `visitor-counter` | `before` hook + cookie to tell new vs. returning visitors |
263
+ | `image-upload` | `multipart/form-data`, saving into `public/`, a gallery |
264
+ | `poll` | WebSocket used to push live results to every open tab |
265
+ | `json-api` | A headless JSON REST API (no HTML views at all) |
266
+ | `login-demo` | Session-based auth with a `before`-hook route guard |
267
+
268
+ ```
269
+ cd examples/chatroom
270
+ jimmu server
271
+ ```
272
+
273
+ (If you're running straight from this source checkout rather than an
274
+ installed gem, use `ruby -I../../lib ../../exe/jimmu server` instead, or
275
+ `gem install --local` the built `.gem` first.)
276
+
277
+ ## License
278
+
279
+ MIT. See `LICENSE`.
@@ -0,0 +1,21 @@
1
+ # Simple bulletin board (BBS).
2
+ # Demonstrates: SQLite, forms/POST, session (remembers your name),
3
+ # before hook, file-based routing.
4
+
5
+ port 3000
6
+
7
+ db = sqlite "db/bbs.db"
8
+ db.exec <<~SQL
9
+ CREATE TABLE IF NOT EXISTS posts (
10
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
11
+ author TEXT NOT NULL,
12
+ body TEXT NOT NULL,
13
+ created_at TEXT NOT NULL
14
+ )
15
+ SQL
16
+
17
+ before do
18
+ log "#{request.method} #{request.path}"
19
+ end
20
+
21
+ run
@@ -0,0 +1,15 @@
1
+ :root{color-scheme:light dark}
2
+ *{box-sizing:border-box}
3
+ body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;background:#f4f5f7;margin:0;color:#1a1a1a}
4
+ .wrap{max-width:640px;margin:0 auto;padding:32px 20px}
5
+ header h1{font-size:20px}
6
+ header h1 a{color:inherit;text-decoration:none}
7
+ .post-form{display:flex;flex-direction:column;gap:8px;background:#fff;padding:16px;border-radius:10px;box-shadow:0 1px 3px rgba(0,0,0,.08);margin-bottom:20px}
8
+ .post-form input,.post-form textarea{font:inherit;padding:10px;border:1px solid #ddd;border-radius:6px;width:100%}
9
+ .post-form textarea{min-height:80px;resize:vertical}
10
+ .post-form button{align-self:flex-end;background:#3b6fe0;color:#fff;border:0;padding:10px 20px;border-radius:6px;cursor:pointer;font-weight:600}
11
+ .count{color:#777;font-size:13px}
12
+ .posts{list-style:none;padding:0;display:flex;flex-direction:column;gap:10px}
13
+ .post{background:#fff;padding:14px 16px;border-radius:10px;box-shadow:0 1px 3px rgba(0,0,0,.06)}
14
+ .post-meta{font-size:12px;color:#888;margin-bottom:6px}
15
+ .post-body{white-space:pre-wrap;word-break:break-word}
@@ -0,0 +1 @@
1
+ <h1>404</h1><p><a href="/">Home</a></p>
@@ -0,0 +1 @@
1
+ <h1>500</h1><p>Something went wrong.</p>
@@ -0,0 +1,29 @@
1
+ <%
2
+ if request.method == "POST"
3
+ author = params[:author].to_s.strip
4
+ body_text = params[:body].to_s.strip
5
+ author = "Anonymous" if author.empty?
6
+ session[:author] = author
7
+ unless body_text.empty?
8
+ db.exec("INSERT INTO posts(author, body, created_at) VALUES (?, ?, ?)", author, body_text, Time.now.strftime("%Y-%m-%d %H:%M:%S"))
9
+ end
10
+ redirect "/"
11
+ end
12
+ posts = db.query("SELECT * FROM posts ORDER BY id DESC")
13
+ %>
14
+ <form method="post" action="/" class="post-form">
15
+ <input type="text" name="author" placeholder="Your name" value="<%= session[:author] %>">
16
+ <textarea name="body" placeholder="Write something..." required></textarea>
17
+ <button type="submit">Post</button>
18
+ </form>
19
+
20
+ <p class="count"><%= posts.length %> post<%= posts.length == 1 ? "" : "s" %></p>
21
+
22
+ <ul class="posts">
23
+ <% posts.each do |post| %>
24
+ <li class="post">
25
+ <div class="post-meta"><strong><%= CGI.escapeHTML(post[:author]) %></strong> &middot; <%= post[:created_at] %></div>
26
+ <div class="post-body"><%= CGI.escapeHTML(post[:body]).gsub("\n", "<br>") %></div>
27
+ </li>
28
+ <% end %>
29
+ </ul>
@@ -0,0 +1,15 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>Jimmu BBS</title>
7
+ <link rel="stylesheet" href="/css/style.css">
8
+ </head>
9
+ <body>
10
+ <div class="wrap">
11
+ <header><h1><a href="/">Jimmu BBS</a></h1></header>
12
+ <%= yield %>
13
+ </div>
14
+ </body>
15
+ </html>
@@ -0,0 +1,28 @@
1
+ # Minimal blog.
2
+ # Demonstrates: SQLite, dynamic routes (/post/[id]), a create form,
3
+ # 404 for missing posts, layout.
4
+
5
+ port 3000
6
+
7
+ db = sqlite "db/blog.db"
8
+ db.exec <<~SQL
9
+ CREATE TABLE IF NOT EXISTS posts (
10
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
11
+ title TEXT NOT NULL,
12
+ body TEXT NOT NULL,
13
+ created_at TEXT NOT NULL
14
+ )
15
+ SQL
16
+
17
+ if db.query("SELECT COUNT(*) AS c FROM posts").first[:c].zero?
18
+ db.exec("INSERT INTO posts(title, body, created_at) VALUES (?, ?, ?)",
19
+ "Welcome to Jimmu",
20
+ "This is your first post. Edit it, delete it, or write a new one from /new.\n\nJimmu renders this body with basic paragraph formatting -- try leaving a blank line between paragraphs.",
21
+ Time.now.strftime("%Y-%m-%d %H:%M"))
22
+ end
23
+
24
+ before do
25
+ log "#{request.method} #{request.path}"
26
+ end
27
+
28
+ run
@@ -0,0 +1,18 @@
1
+ :root{color-scheme:light dark}
2
+ *{box-sizing:border-box}
3
+ body{font-family:Georgia,"Times New Roman",serif;background:#fffdf9;color:#2a2a2a;margin:0}
4
+ .wrap{max-width:640px;margin:0 auto;padding:40px 20px}
5
+ header{display:flex;justify-content:space-between;align-items:baseline;border-bottom:2px solid #222;padding-bottom:14px;margin-bottom:24px;font-family:-apple-system,sans-serif}
6
+ header h1{font-size:22px;margin:0}
7
+ header h1 a{color:inherit;text-decoration:none}
8
+ .new-link{font-size:13px;color:#3b6fe0;text-decoration:none}
9
+ .post-list{list-style:none;padding:0;display:flex;flex-direction:column;gap:18px}
10
+ .post-list .title{font-size:19px;color:#1a1a1a;text-decoration:none;font-weight:bold}
11
+ .post-list .date{font-size:13px;color:#888;font-family:-apple-system,sans-serif;margin-top:2px}
12
+ article .date{font-size:13px;color:#888;font-family:-apple-system,sans-serif;margin-bottom:20px}
13
+ article .body p{line-height:1.8;font-size:17px}
14
+ .post-form{display:flex;flex-direction:column;gap:10px;font-family:-apple-system,sans-serif}
15
+ .post-form input,.post-form textarea{font:inherit;padding:10px;border:1px solid #ccc;border-radius:6px}
16
+ .post-form textarea{min-height:220px;font-family:Georgia,serif;font-size:15px}
17
+ .post-form button{align-self:flex-start;background:#222;color:#fff;border:0;padding:10px 22px;border-radius:6px;cursor:pointer}
18
+ a{color:#3b6fe0}
@@ -0,0 +1 @@
1
+ <h1>404</h1><p>That post doesn't exist.</p><p><a href="/">&larr; all posts</a></p>
@@ -0,0 +1 @@
1
+ <h1>500</h1>
@@ -0,0 +1,9 @@
1
+ <% posts = db.query("SELECT * FROM posts ORDER BY id DESC") %>
2
+ <ul class="post-list">
3
+ <% posts.each do |post| %>
4
+ <li>
5
+ <a class="title" href="/post/<%= post[:id] %>"><%= CGI.escapeHTML(post[:title]) %></a>
6
+ <div class="date"><%= post[:created_at] %></div>
7
+ </li>
8
+ <% end %>
9
+ </ul>
@@ -0,0 +1,15 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>Jimmu Blog</title>
7
+ <link rel="stylesheet" href="/css/style.css">
8
+ </head>
9
+ <body>
10
+ <div class="wrap">
11
+ <header><h1><a href="/">Jimmu Blog</a></h1><a class="new-link" href="/new">+ New post</a></header>
12
+ <%= yield %>
13
+ </div>
14
+ </body>
15
+ </html>
@@ -0,0 +1,18 @@
1
+ <%
2
+ if request.method == "POST"
3
+ title = params[:title].to_s.strip
4
+ body_text = params[:body].to_s.strip
5
+ if title.empty? || body_text.empty?
6
+ status 422
7
+ else
8
+ db.exec("INSERT INTO posts(title, body, created_at) VALUES (?, ?, ?)", title, body_text, Time.now.strftime("%Y-%m-%d %H:%M"))
9
+ redirect "/"
10
+ end
11
+ end
12
+ %>
13
+ <h2>New post</h2>
14
+ <form method="post" action="/new" class="post-form">
15
+ <input type="text" name="title" placeholder="Title" required value="<%= CGI.escapeHTML(params[:title].to_s) %>">
16
+ <textarea name="body" placeholder="Write your post..." required><%= CGI.escapeHTML(params[:body].to_s) %></textarea>
17
+ <button type="submit">Publish</button>
18
+ </form>
@@ -0,0 +1,17 @@
1
+ <%
2
+ post = db.query("SELECT * FROM posts WHERE id = ?", params[:id]).first
3
+ unless post
4
+ status 404
5
+ render "404"
6
+ end
7
+ %>
8
+ <article>
9
+ <h2><%= CGI.escapeHTML(post[:title]) %></h2>
10
+ <div class="date"><%= post[:created_at] %></div>
11
+ <div class="body">
12
+ <% post[:body].split("\n\n").each do |para| %>
13
+ <p><%= CGI.escapeHTML(para) %></p>
14
+ <% end %>
15
+ </div>
16
+ </article>
17
+ <p><a href="/">&larr; all posts</a></p>
@@ -0,0 +1,44 @@
1
+ # Named chat rooms over WebSocket.
2
+ # Pick a name, create (or join) a room, chat in real time.
3
+ # Demonstrates: WebSocket, dynamic routing, request.query_params,
4
+ # a hand-rolled broadcast registry built from plain Ruby (Jimmu has
5
+ # no special "broadcast" API -- this is all you need to build one).
6
+
7
+ port 3000
8
+
9
+ ROOMS = Hash.new { |h, k| h[k] = [] }
10
+ ROOMS_LOCK = Mutex.new
11
+
12
+ def broadcast(room, message)
13
+ ROOMS_LOCK.synchronize { ROOMS[room].dup }.each do |conn|
14
+ conn.send(message) if conn.open?
15
+ end
16
+ end
17
+
18
+ before do
19
+ log "#{request.method} #{request.path}"
20
+ end
21
+
22
+ websocket "/ws/[room]" do
23
+ room = params[:room]
24
+ name = request.query_params[:name].to_s
25
+ name = "Guest#{rand(1000)}" if name.strip.empty?
26
+
27
+ ROOMS_LOCK.synchronize { ROOMS[room] << self }
28
+ broadcast(room, { type: "system", text: "#{name} joined the room.", count: ROOMS[room].length }.to_json)
29
+ log "chat: #{name} joined ##{room} (#{ROOMS[room].length} online)"
30
+
31
+ on_message do |raw|
32
+ text = raw.to_s[0, 2000]
33
+ next if text.strip.empty?
34
+ broadcast(room, { type: "message", author: name, text: text }.to_json)
35
+ end
36
+
37
+ on_close do
38
+ ROOMS_LOCK.synchronize { ROOMS[room].delete(self) }
39
+ broadcast(room, { type: "system", text: "#{name} left the room.", count: ROOMS[room].length }.to_json)
40
+ log "chat: #{name} left ##{room}"
41
+ end
42
+ end
43
+
44
+ run
@@ -0,0 +1,15 @@
1
+ :root{color-scheme:light dark}
2
+ *{box-sizing:border-box}
3
+ body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;background:#0f1115;color:#e7e8ec;margin:0}
4
+ .wrap{max-width:560px;margin:0 auto;padding:32px 20px}
5
+ .center{text-align:center;padding-top:80px}
6
+ form{display:flex;gap:8px;margin-top:20px}
7
+ input{flex:1;padding:12px;border-radius:8px;border:1px solid #333;background:#1a1d24;color:#fff;font-size:15px}
8
+ button{padding:12px 18px;border-radius:8px;border:0;background:#5865f2;color:#fff;font-weight:600;cursor:pointer}
9
+ .chat h1{display:flex;align-items:center;gap:10px;font-size:20px}
10
+ .badge{font-size:12px;color:#9aa0ab;font-weight:400}
11
+ .log{height:50vh;overflow-y:auto;background:#1a1d24;border-radius:10px;padding:14px;margin-bottom:14px;display:flex;flex-direction:column;gap:6px}
12
+ .line{font-size:14px;line-height:1.5;word-break:break-word}
13
+ .line.system{color:#7a8093;font-style:italic;font-size:13px}
14
+ .line.message strong{color:#8ab4ff}
15
+ a{color:#8ab4ff}
@@ -0,0 +1 @@
1
+ <div class="wrap center"><h1>404</h1><p><a href="/">Home</a></p></div>
@@ -0,0 +1 @@
1
+ <div class="wrap center"><h1>500</h1></div>
@@ -0,0 +1,18 @@
1
+ <div class="wrap center">
2
+ <h1>Jimmu Chat</h1>
3
+ <p>Pick a name and a room. If the room doesn't exist yet, it's created the moment you join.</p>
4
+ <form id="join-form">
5
+ <input type="text" id="name" placeholder="Your name" required maxlength="24">
6
+ <input type="text" id="room" placeholder="Room name (e.g. lobby)" required maxlength="24">
7
+ <button type="submit">Join room</button>
8
+ </form>
9
+ </div>
10
+ <script>
11
+ document.getElementById('join-form').addEventListener('submit', function (e) {
12
+ e.preventDefault();
13
+ var name = document.getElementById('name').value.trim();
14
+ var room = document.getElementById('room').value.trim().toLowerCase().replace(/[^a-z0-9_-]/g, '-');
15
+ if (!name || !room) return;
16
+ window.location.href = '/room/' + encodeURIComponent(room) + '?name=' + encodeURIComponent(name);
17
+ });
18
+ </script>
@@ -0,0 +1,12 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>Jimmu Chat</title>
7
+ <link rel="stylesheet" href="/css/style.css">
8
+ </head>
9
+ <body>
10
+ <%= yield %>
11
+ </body>
12
+ </html>