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.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/README.md +279 -0
- data/examples/bbs/app.rb +21 -0
- data/examples/bbs/public/css/style.css +15 -0
- data/examples/bbs/views/404.erb +1 -0
- data/examples/bbs/views/500.erb +1 -0
- data/examples/bbs/views/index.erb +29 -0
- data/examples/bbs/views/layout.erb +15 -0
- data/examples/blog/app.rb +28 -0
- data/examples/blog/public/css/style.css +18 -0
- data/examples/blog/views/404.erb +1 -0
- data/examples/blog/views/500.erb +1 -0
- data/examples/blog/views/index.erb +9 -0
- data/examples/blog/views/layout.erb +15 -0
- data/examples/blog/views/new.erb +18 -0
- data/examples/blog/views/post/[id].erb +17 -0
- data/examples/chatroom/app.rb +44 -0
- data/examples/chatroom/public/css/style.css +15 -0
- data/examples/chatroom/views/404.erb +1 -0
- data/examples/chatroom/views/500.erb +1 -0
- data/examples/chatroom/views/index.erb +18 -0
- data/examples/chatroom/views/layout.erb +12 -0
- data/examples/chatroom/views/room/[name].erb +47 -0
- data/examples/counter/app.rb +15 -0
- data/examples/counter/public/css/style.css +8 -0
- data/examples/counter/public/js/app.js +11 -0
- data/examples/counter/views/404.erb +1 -0
- data/examples/counter/views/500.erb +1 -0
- data/examples/counter/views/api/increment.erb +5 -0
- data/examples/counter/views/api/reset.erb +4 -0
- data/examples/counter/views/index.erb +8 -0
- data/examples/counter/views/layout.erb +13 -0
- data/examples/hello-world/app.rb +6 -0
- data/examples/hello-world/public/css/style.css +3 -0
- data/examples/hello-world/views/404.erb +2 -0
- data/examples/hello-world/views/500.erb +1 -0
- data/examples/hello-world/views/greet/[name].erb +2 -0
- data/examples/hello-world/views/index.erb +3 -0
- data/examples/hello-world/views/layout.erb +12 -0
- data/examples/image-upload/app.rb +22 -0
- data/examples/image-upload/public/css/style.css +11 -0
- data/examples/image-upload/views/404.erb +1 -0
- data/examples/image-upload/views/500.erb +1 -0
- data/examples/image-upload/views/index.erb +31 -0
- data/examples/image-upload/views/layout.erb +15 -0
- data/examples/json-api/app.rb +27 -0
- data/examples/json-api/views/404.erb +2 -0
- data/examples/json-api/views/500.erb +2 -0
- data/examples/json-api/views/api/notes/[id].erb +33 -0
- data/examples/json-api/views/api/notes.erb +20 -0
- data/examples/json-api/views/index.erb +13 -0
- data/examples/login-demo/app.rb +31 -0
- data/examples/login-demo/public/css/style.css +12 -0
- data/examples/login-demo/views/404.erb +1 -0
- data/examples/login-demo/views/500.erb +1 -0
- data/examples/login-demo/views/index.erb +6 -0
- data/examples/login-demo/views/layout.erb +24 -0
- data/examples/login-demo/views/login.erb +22 -0
- data/examples/login-demo/views/logout.erb +4 -0
- data/examples/login-demo/views/mypage/index.erb +3 -0
- data/examples/login-demo/views/signup.erb +24 -0
- data/examples/poll/app.rb +55 -0
- data/examples/poll/public/css/style.css +10 -0
- data/examples/poll/public/js/app.js +24 -0
- data/examples/poll/views/404.erb +1 -0
- data/examples/poll/views/500.erb +1 -0
- data/examples/poll/views/index.erb +6 -0
- data/examples/poll/views/layout.erb +13 -0
- data/examples/todo/app.rb +21 -0
- data/examples/todo/public/css/style.css +17 -0
- data/examples/todo/views/404.erb +1 -0
- data/examples/todo/views/500.erb +1 -0
- data/examples/todo/views/delete/[id].erb +4 -0
- data/examples/todo/views/index.erb +27 -0
- data/examples/todo/views/layout.erb +15 -0
- data/examples/todo/views/toggle/[id].erb +5 -0
- data/examples/url-shortener/app.rb +39 -0
- data/examples/url-shortener/public/css/style.css +13 -0
- data/examples/url-shortener/views/404.erb +1 -0
- data/examples/url-shortener/views/500.erb +1 -0
- data/examples/url-shortener/views/[code].erb +10 -0
- data/examples/url-shortener/views/api/shorten.erb +13 -0
- data/examples/url-shortener/views/index.erb +35 -0
- data/examples/url-shortener/views/layout.erb +15 -0
- data/examples/visitor-counter/app.rb +22 -0
- data/examples/visitor-counter/public/css/style.css +4 -0
- data/examples/visitor-counter/views/404.erb +1 -0
- data/examples/visitor-counter/views/500.erb +1 -0
- data/examples/visitor-counter/views/index.erb +11 -0
- data/examples/visitor-counter/views/layout.erb +12 -0
- data/exe/jimmu +5 -0
- data/lib/jimmu.rb +1916 -0
- 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`.
|
data/examples/bbs/app.rb
ADDED
|
@@ -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> · <%= 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="/">← 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="/">← 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>
|