funapi 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.
- checksums.yaml +7 -0
- data/.claude/25-09-01-OPENAPI_IMPLEMENTATION.md +233 -0
- data/.claude/25-09-05-RESPONSE_SCHEMA.md +383 -0
- data/.claude/25-09-10-OPENAPI_PLAN.md +219 -0
- data/.claude/25-10-26-MIDDLEWARE_IMPLEMENTATION.md +230 -0
- data/.claude/25-10-26-MIDDLEWARE_PLAN.md +353 -0
- data/.claude/25-10-27-BACKGROUND_TASKS_ANALYSIS.md +325 -0
- data/.claude/25-10-27-DEPENDENCY_IMPLEMENTATION_SUMMARY.md +325 -0
- data/.claude/25-10-27-DEPENDENCY_INJECTION_PLAN.md +753 -0
- data/.claude/25-12-24-LIFECYCLE_HOOKS_PLAN.md +421 -0
- data/.claude/25-12-24-PUBLISHING_AND_DOGFOODING_PLAN.md +327 -0
- data/.claude/25-12-24-TEMPLATE_RENDERING_PLAN.md +704 -0
- data/.claude/DECISIONS.md +397 -0
- data/.claude/PROJECT_PLAN.md +80 -0
- data/.claude/TESTING_PLAN.md +285 -0
- data/.claude/TESTING_STATUS.md +157 -0
- data/.tool-versions +1 -0
- data/AGENTS.md +416 -0
- data/CHANGELOG.md +5 -0
- data/CODE_OF_CONDUCT.md +132 -0
- data/LICENSE.txt +21 -0
- data/README.md +660 -0
- data/Rakefile +10 -0
- data/docs +8 -0
- data/docs-site/.gitignore +3 -0
- data/docs-site/Gemfile +9 -0
- data/docs-site/app.rb +138 -0
- data/docs-site/content/essential/handler.md +156 -0
- data/docs-site/content/essential/lifecycle.md +161 -0
- data/docs-site/content/essential/middleware.md +201 -0
- data/docs-site/content/essential/openapi.md +155 -0
- data/docs-site/content/essential/routing.md +123 -0
- data/docs-site/content/essential/validation.md +166 -0
- data/docs-site/content/getting-started/at-glance.md +82 -0
- data/docs-site/content/getting-started/key-concepts.md +150 -0
- data/docs-site/content/getting-started/quick-start.md +127 -0
- data/docs-site/content/index.md +81 -0
- data/docs-site/content/patterns/async-operations.md +137 -0
- data/docs-site/content/patterns/background-tasks.md +143 -0
- data/docs-site/content/patterns/database.md +175 -0
- data/docs-site/content/patterns/dependencies.md +141 -0
- data/docs-site/content/patterns/deployment.md +212 -0
- data/docs-site/content/patterns/error-handling.md +184 -0
- data/docs-site/content/patterns/response-schema.md +159 -0
- data/docs-site/content/patterns/templates.md +193 -0
- data/docs-site/content/patterns/testing.md +218 -0
- data/docs-site/mise.toml +2 -0
- data/docs-site/public/css/style.css +234 -0
- data/docs-site/templates/layouts/docs.html.erb +28 -0
- data/docs-site/templates/page.html.erb +3 -0
- data/docs-site/templates/partials/_nav.html.erb +19 -0
- data/examples/background_tasks_demo.rb +159 -0
- data/examples/demo_middleware.rb +55 -0
- data/examples/demo_openapi.rb +63 -0
- data/examples/dependency_block_demo.rb +150 -0
- data/examples/dependency_cleanup_demo.rb +146 -0
- data/examples/dependency_injection_demo.rb +200 -0
- data/examples/lifecycle_demo.rb +57 -0
- data/examples/middleware_demo.rb +74 -0
- data/examples/templates/layouts/application.html.erb +66 -0
- data/examples/templates/todos/_todo.html.erb +15 -0
- data/examples/templates/todos/index.html.erb +12 -0
- data/examples/templates_demo.rb +87 -0
- data/lib/funapi/application.rb +521 -0
- data/lib/funapi/async.rb +57 -0
- data/lib/funapi/background_tasks.rb +52 -0
- data/lib/funapi/config.rb +23 -0
- data/lib/funapi/database/sequel/fibered_connection_pool.rb +87 -0
- data/lib/funapi/dependency_wrapper.rb +66 -0
- data/lib/funapi/depends.rb +138 -0
- data/lib/funapi/exceptions.rb +72 -0
- data/lib/funapi/middleware/base.rb +13 -0
- data/lib/funapi/middleware/cors.rb +23 -0
- data/lib/funapi/middleware/request_logger.rb +32 -0
- data/lib/funapi/middleware/trusted_host.rb +34 -0
- data/lib/funapi/middleware.rb +4 -0
- data/lib/funapi/openapi/schema_converter.rb +85 -0
- data/lib/funapi/openapi/spec_generator.rb +179 -0
- data/lib/funapi/router.rb +43 -0
- data/lib/funapi/schema.rb +65 -0
- data/lib/funapi/server/falcon.rb +38 -0
- data/lib/funapi/template_response.rb +17 -0
- data/lib/funapi/templates.rb +111 -0
- data/lib/funapi/version.rb +5 -0
- data/lib/funapi.rb +14 -0
- data/sig/fun_api.rbs +499 -0
- metadata +220 -0
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Templates
|
|
3
|
+
---
|
|
4
|
+
|
|
5
|
+
# Templates
|
|
6
|
+
|
|
7
|
+
Render ERB templates for HTML responses. Perfect for HTMX-powered applications.
|
|
8
|
+
|
|
9
|
+
## Setup
|
|
10
|
+
|
|
11
|
+
```ruby
|
|
12
|
+
require 'funapi/templates'
|
|
13
|
+
|
|
14
|
+
templates = FunApi::Templates.new(
|
|
15
|
+
directory: 'templates',
|
|
16
|
+
layout: 'layouts/application.html.erb' # Optional default layout
|
|
17
|
+
)
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
## Basic Rendering
|
|
21
|
+
|
|
22
|
+
Return a `TemplateResponse` from your handler:
|
|
23
|
+
|
|
24
|
+
```ruby
|
|
25
|
+
api.get '/' do |input, req, task|
|
|
26
|
+
templates.response('home.html.erb', title: 'Home', user: current_user)
|
|
27
|
+
end
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
Template (`templates/home.html.erb`):
|
|
31
|
+
|
|
32
|
+
```erb
|
|
33
|
+
<h1>Welcome, <%= user[:name] %>!</h1>
|
|
34
|
+
<p>This is the home page.</p>
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
## Layouts
|
|
38
|
+
|
|
39
|
+
### Default Layout
|
|
40
|
+
|
|
41
|
+
Set a default layout in the constructor:
|
|
42
|
+
|
|
43
|
+
```ruby
|
|
44
|
+
templates = FunApi::Templates.new(
|
|
45
|
+
directory: 'templates',
|
|
46
|
+
layout: 'layouts/application.html.erb'
|
|
47
|
+
)
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
Layout (`templates/layouts/application.html.erb`):
|
|
51
|
+
|
|
52
|
+
```erb
|
|
53
|
+
<!DOCTYPE html>
|
|
54
|
+
<html>
|
|
55
|
+
<head>
|
|
56
|
+
<title><%= title %></title>
|
|
57
|
+
</head>
|
|
58
|
+
<body>
|
|
59
|
+
<nav>...</nav>
|
|
60
|
+
|
|
61
|
+
<main>
|
|
62
|
+
<%= yield_content %>
|
|
63
|
+
</main>
|
|
64
|
+
|
|
65
|
+
<footer>...</footer>
|
|
66
|
+
</body>
|
|
67
|
+
</html>
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
Use `yield_content` to insert the template content.
|
|
71
|
+
|
|
72
|
+
### Disabling Layout
|
|
73
|
+
|
|
74
|
+
For partials or HTMX responses, disable the layout:
|
|
75
|
+
|
|
76
|
+
```ruby
|
|
77
|
+
api.post '/items' do |input, req, task|
|
|
78
|
+
item = create_item(input[:body])
|
|
79
|
+
templates.response('items/_item.html.erb', layout: false, item: item, status: 201)
|
|
80
|
+
end
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
### Different Layouts
|
|
84
|
+
|
|
85
|
+
Use `with_layout` for route groups:
|
|
86
|
+
|
|
87
|
+
```ruby
|
|
88
|
+
templates = FunApi::Templates.new(directory: 'templates')
|
|
89
|
+
|
|
90
|
+
public_templates = templates.with_layout('layouts/public.html.erb')
|
|
91
|
+
admin_templates = templates.with_layout('layouts/admin.html.erb')
|
|
92
|
+
|
|
93
|
+
api.get '/' do |input, req, task|
|
|
94
|
+
public_templates.response('home.html.erb', title: 'Home')
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
api.get '/admin' do |input, req, task|
|
|
98
|
+
admin_templates.response('admin/dashboard.html.erb', title: 'Dashboard')
|
|
99
|
+
end
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
## Partials
|
|
103
|
+
|
|
104
|
+
Render partials within templates:
|
|
105
|
+
|
|
106
|
+
```erb
|
|
107
|
+
<!-- templates/items/index.html.erb -->
|
|
108
|
+
<ul id="items">
|
|
109
|
+
<% items.each do |item| %>
|
|
110
|
+
<%= render_partial('items/_item.html.erb', item: item) %>
|
|
111
|
+
<% end %>
|
|
112
|
+
</ul>
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
```erb
|
|
116
|
+
<!-- templates/items/_item.html.erb -->
|
|
117
|
+
<li id="item-<%= item[:id] %>">
|
|
118
|
+
<%= item[:name] %>
|
|
119
|
+
</li>
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
## With HTMX
|
|
123
|
+
|
|
124
|
+
FunApi templates work great with HTMX:
|
|
125
|
+
|
|
126
|
+
```ruby
|
|
127
|
+
# Full page with layout
|
|
128
|
+
api.get '/items' do |input, req, task|
|
|
129
|
+
items = fetch_items
|
|
130
|
+
templates.response('items/index.html.erb', items: items)
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# Partial for HTMX insertion
|
|
134
|
+
api.post '/items', body: ItemSchema do |input, req, task|
|
|
135
|
+
item = create_item(input[:body])
|
|
136
|
+
templates.response('items/_item.html.erb', layout: false, item: item, status: 201)
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# Empty response for HTMX delete
|
|
140
|
+
api.delete '/items/:id' do |input, req, task|
|
|
141
|
+
delete_item(input[:path]['id'])
|
|
142
|
+
FunApi::TemplateResponse.new('')
|
|
143
|
+
end
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
Template with HTMX:
|
|
147
|
+
|
|
148
|
+
```erb
|
|
149
|
+
<form hx-post="/items" hx-target="#items" hx-swap="beforeend">
|
|
150
|
+
<input type="text" name="name" placeholder="New item">
|
|
151
|
+
<button type="submit">Add</button>
|
|
152
|
+
</form>
|
|
153
|
+
|
|
154
|
+
<ul id="items">
|
|
155
|
+
<% items.each do |item| %>
|
|
156
|
+
<%= render_partial('items/_item.html.erb', item: item) %>
|
|
157
|
+
<% end %>
|
|
158
|
+
</ul>
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
## Template Variables
|
|
162
|
+
|
|
163
|
+
Pass any variables as keyword arguments:
|
|
164
|
+
|
|
165
|
+
```ruby
|
|
166
|
+
templates.response('page.html.erb',
|
|
167
|
+
title: 'My Page',
|
|
168
|
+
user: current_user,
|
|
169
|
+
items: items,
|
|
170
|
+
flash: { notice: 'Success!' }
|
|
171
|
+
)
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
Access them directly in templates:
|
|
175
|
+
|
|
176
|
+
```erb
|
|
177
|
+
<h1><%= title %></h1>
|
|
178
|
+
<p>Hello, <%= user[:name] %></p>
|
|
179
|
+
|
|
180
|
+
<% if flash[:notice] %>
|
|
181
|
+
<div class="notice"><%= flash[:notice] %></div>
|
|
182
|
+
<% end %>
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
## Custom Status and Headers
|
|
186
|
+
|
|
187
|
+
```ruby
|
|
188
|
+
templates.response('error.html.erb',
|
|
189
|
+
status: 404,
|
|
190
|
+
headers: { 'X-Custom' => 'value' },
|
|
191
|
+
message: 'Not found'
|
|
192
|
+
)
|
|
193
|
+
```
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Testing
|
|
3
|
+
---
|
|
4
|
+
|
|
5
|
+
# Testing
|
|
6
|
+
|
|
7
|
+
Test FunApi applications with any Ruby testing framework.
|
|
8
|
+
|
|
9
|
+
## Basic Setup with Minitest
|
|
10
|
+
|
|
11
|
+
```ruby
|
|
12
|
+
require 'minitest/autorun'
|
|
13
|
+
require 'rack/test'
|
|
14
|
+
require 'async'
|
|
15
|
+
|
|
16
|
+
class TestMyApi < Minitest::Test
|
|
17
|
+
include Rack::Test::Methods
|
|
18
|
+
|
|
19
|
+
def app
|
|
20
|
+
@app ||= FunApi::App.new do |api|
|
|
21
|
+
api.get '/hello' do |input, req, task|
|
|
22
|
+
[{ message: 'Hello!' }, 200]
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def async_request(method, path, **options)
|
|
28
|
+
Async do
|
|
29
|
+
send(method, path, **options)
|
|
30
|
+
last_response
|
|
31
|
+
end.wait
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def test_hello
|
|
35
|
+
response = async_request(:get, '/hello')
|
|
36
|
+
assert_equal 200, response.status
|
|
37
|
+
assert_equal({ 'message' => 'Hello!' }, JSON.parse(response.body))
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
## Testing with RSpec
|
|
43
|
+
|
|
44
|
+
```ruby
|
|
45
|
+
require 'rack/test'
|
|
46
|
+
require 'async'
|
|
47
|
+
|
|
48
|
+
RSpec.describe 'My API' do
|
|
49
|
+
include Rack::Test::Methods
|
|
50
|
+
|
|
51
|
+
let(:app) do
|
|
52
|
+
FunApi::App.new do |api|
|
|
53
|
+
api.get '/hello' do |input, req, task|
|
|
54
|
+
[{ message: 'Hello!' }, 200]
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def async_request(method, path, **options)
|
|
60
|
+
Async do
|
|
61
|
+
send(method, path, **options)
|
|
62
|
+
last_response
|
|
63
|
+
end.wait
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
it 'returns hello' do
|
|
67
|
+
response = async_request(:get, '/hello')
|
|
68
|
+
expect(response.status).to eq(200)
|
|
69
|
+
expect(JSON.parse(response.body)).to eq({ 'message' => 'Hello!' })
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
## Testing POST Requests
|
|
75
|
+
|
|
76
|
+
```ruby
|
|
77
|
+
def test_create_user
|
|
78
|
+
response = async_request(:post, '/users',
|
|
79
|
+
input: JSON.dump({ name: 'Alice', email: 'alice@example.com' }),
|
|
80
|
+
'CONTENT_TYPE' => 'application/json'
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
assert_equal 201, response.status
|
|
84
|
+
body = JSON.parse(response.body)
|
|
85
|
+
assert_equal 'Alice', body['created']['name']
|
|
86
|
+
end
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
## Testing Validation Errors
|
|
90
|
+
|
|
91
|
+
```ruby
|
|
92
|
+
def test_validation_error
|
|
93
|
+
response = async_request(:post, '/users',
|
|
94
|
+
input: JSON.dump({ name: 'Alice' }), # Missing email
|
|
95
|
+
'CONTENT_TYPE' => 'application/json'
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
assert_equal 422, response.status
|
|
99
|
+
body = JSON.parse(response.body)
|
|
100
|
+
assert body['detail'].any? { |e| e['loc'].include?('email') }
|
|
101
|
+
end
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
## Testing with Dependencies
|
|
105
|
+
|
|
106
|
+
Mock dependencies for testing:
|
|
107
|
+
|
|
108
|
+
```ruby
|
|
109
|
+
def app
|
|
110
|
+
@app ||= FunApi::App.new do |api|
|
|
111
|
+
api.register(:db) { MockDatabase.new }
|
|
112
|
+
|
|
113
|
+
api.get '/users', depends: [:db] do |input, req, task, db:|
|
|
114
|
+
[{ users: db.all_users }, 200]
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
class MockDatabase
|
|
120
|
+
def all_users
|
|
121
|
+
[{ id: 1, name: 'Test User' }]
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def test_users_with_mock_db
|
|
126
|
+
response = async_request(:get, '/users')
|
|
127
|
+
assert_equal 200, response.status
|
|
128
|
+
assert_equal 1, JSON.parse(response.body)['users'].length
|
|
129
|
+
end
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
## Testing Path Parameters
|
|
133
|
+
|
|
134
|
+
```ruby
|
|
135
|
+
def test_get_user_by_id
|
|
136
|
+
response = async_request(:get, '/users/123')
|
|
137
|
+
assert_equal 200, response.status
|
|
138
|
+
assert_equal '123', JSON.parse(response.body)['id']
|
|
139
|
+
end
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
## Testing Query Parameters
|
|
143
|
+
|
|
144
|
+
```ruby
|
|
145
|
+
def test_search
|
|
146
|
+
response = async_request(:get, '/search?q=ruby&limit=10')
|
|
147
|
+
assert_equal 200, response.status
|
|
148
|
+
end
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
## Testing Headers
|
|
152
|
+
|
|
153
|
+
```ruby
|
|
154
|
+
def test_auth_header
|
|
155
|
+
response = async_request(:get, '/protected',
|
|
156
|
+
'HTTP_AUTHORIZATION' => 'Bearer token123'
|
|
157
|
+
)
|
|
158
|
+
assert_equal 200, response.status
|
|
159
|
+
end
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
## Integration Testing with Real Database
|
|
163
|
+
|
|
164
|
+
```ruby
|
|
165
|
+
class TestWithDatabase < Minitest::Test
|
|
166
|
+
def setup
|
|
167
|
+
@db = PG.connect(ENV['TEST_DATABASE_URL'])
|
|
168
|
+
@db.exec("TRUNCATE users")
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def teardown
|
|
172
|
+
@db.close
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def app
|
|
176
|
+
FunApi::App.new do |api|
|
|
177
|
+
api.register(:db) { @db }
|
|
178
|
+
# routes...
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def test_creates_user_in_database
|
|
183
|
+
async_request(:post, '/users',
|
|
184
|
+
input: JSON.dump({ name: 'Alice', email: 'alice@test.com' }),
|
|
185
|
+
'CONTENT_TYPE' => 'application/json'
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
result = @db.exec("SELECT * FROM users WHERE email = 'alice@test.com'")
|
|
189
|
+
assert_equal 1, result.ntuples
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
## Helper Module
|
|
195
|
+
|
|
196
|
+
Extract common test helpers:
|
|
197
|
+
|
|
198
|
+
```ruby
|
|
199
|
+
module FunApiTestHelpers
|
|
200
|
+
def async_request(method, path, **options)
|
|
201
|
+
Async do
|
|
202
|
+
send(method, path, **options)
|
|
203
|
+
last_response
|
|
204
|
+
end.wait
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
def json_body
|
|
208
|
+
JSON.parse(last_response.body)
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
def post_json(path, body)
|
|
212
|
+
async_request(:post, path,
|
|
213
|
+
input: JSON.dump(body),
|
|
214
|
+
'CONTENT_TYPE' => 'application/json'
|
|
215
|
+
)
|
|
216
|
+
end
|
|
217
|
+
end
|
|
218
|
+
```
|
data/docs-site/mise.toml
ADDED
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
/* Reset and base */
|
|
2
|
+
*, *::before, *::after {
|
|
3
|
+
box-sizing: border-box;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
:root {
|
|
7
|
+
--color-bg: #0a0a0a;
|
|
8
|
+
--color-bg-secondary: #141414;
|
|
9
|
+
--color-text: #e5e5e5;
|
|
10
|
+
--color-text-muted: #a3a3a3;
|
|
11
|
+
--color-primary: #3b82f6;
|
|
12
|
+
--color-primary-hover: #60a5fa;
|
|
13
|
+
--color-border: #262626;
|
|
14
|
+
--color-code-bg: #1a1a1a;
|
|
15
|
+
--font-sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu, sans-serif;
|
|
16
|
+
--font-mono: "SF Mono", SFMono-Regular, Consolas, "Liberation Mono", Menlo, monospace;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
body {
|
|
20
|
+
margin: 0;
|
|
21
|
+
font-family: var(--font-sans);
|
|
22
|
+
font-size: 16px;
|
|
23
|
+
line-height: 1.6;
|
|
24
|
+
color: var(--color-text);
|
|
25
|
+
background-color: var(--color-bg);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
a {
|
|
29
|
+
color: var(--color-primary);
|
|
30
|
+
text-decoration: none;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
a:hover {
|
|
34
|
+
color: var(--color-primary-hover);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/* Layout */
|
|
38
|
+
.layout {
|
|
39
|
+
display: grid;
|
|
40
|
+
grid-template-columns: 260px 1fr;
|
|
41
|
+
grid-template-rows: 60px 1fr;
|
|
42
|
+
min-height: 100vh;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
.header {
|
|
46
|
+
grid-column: 1 / -1;
|
|
47
|
+
display: flex;
|
|
48
|
+
align-items: center;
|
|
49
|
+
justify-content: space-between;
|
|
50
|
+
padding: 0 24px;
|
|
51
|
+
border-bottom: 1px solid var(--color-border);
|
|
52
|
+
background-color: var(--color-bg);
|
|
53
|
+
position: sticky;
|
|
54
|
+
top: 0;
|
|
55
|
+
z-index: 100;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
.logo {
|
|
59
|
+
font-size: 1.25rem;
|
|
60
|
+
font-weight: 700;
|
|
61
|
+
color: var(--color-text);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
.header-nav {
|
|
65
|
+
display: flex;
|
|
66
|
+
gap: 24px;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
.header-nav a {
|
|
70
|
+
color: var(--color-text-muted);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
.header-nav a:hover {
|
|
74
|
+
color: var(--color-text);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
.sidebar {
|
|
78
|
+
padding: 24px;
|
|
79
|
+
border-right: 1px solid var(--color-border);
|
|
80
|
+
background-color: var(--color-bg);
|
|
81
|
+
overflow-y: auto;
|
|
82
|
+
position: sticky;
|
|
83
|
+
top: 60px;
|
|
84
|
+
height: calc(100vh - 60px);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
.content {
|
|
88
|
+
padding: 48px;
|
|
89
|
+
max-width: 800px;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/* Navigation */
|
|
93
|
+
.nav-section {
|
|
94
|
+
margin-bottom: 24px;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
.nav-section-title {
|
|
98
|
+
font-size: 0.75rem;
|
|
99
|
+
font-weight: 600;
|
|
100
|
+
text-transform: uppercase;
|
|
101
|
+
letter-spacing: 0.05em;
|
|
102
|
+
color: var(--color-text-muted);
|
|
103
|
+
margin: 0 0 12px 0;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
.nav-list {
|
|
107
|
+
list-style: none;
|
|
108
|
+
margin: 0;
|
|
109
|
+
padding: 0;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
.nav-item {
|
|
113
|
+
margin: 0;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
.nav-link {
|
|
117
|
+
display: block;
|
|
118
|
+
padding: 6px 12px;
|
|
119
|
+
margin: 2px 0;
|
|
120
|
+
border-radius: 6px;
|
|
121
|
+
color: var(--color-text-muted);
|
|
122
|
+
font-size: 0.9rem;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
.nav-link:hover {
|
|
126
|
+
color: var(--color-text);
|
|
127
|
+
background-color: var(--color-bg-secondary);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
.nav-link.active {
|
|
131
|
+
color: var(--color-primary);
|
|
132
|
+
background-color: rgba(59, 130, 246, 0.1);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/* Article content */
|
|
136
|
+
.article {
|
|
137
|
+
color: var(--color-text);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
.article h1 {
|
|
141
|
+
font-size: 2.5rem;
|
|
142
|
+
font-weight: 700;
|
|
143
|
+
margin: 0 0 16px 0;
|
|
144
|
+
line-height: 1.2;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
.article h2 {
|
|
148
|
+
font-size: 1.5rem;
|
|
149
|
+
font-weight: 600;
|
|
150
|
+
margin: 48px 0 16px 0;
|
|
151
|
+
padding-top: 24px;
|
|
152
|
+
border-top: 1px solid var(--color-border);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
.article h3 {
|
|
156
|
+
font-size: 1.25rem;
|
|
157
|
+
font-weight: 600;
|
|
158
|
+
margin: 32px 0 12px 0;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
.article p {
|
|
162
|
+
margin: 0 0 16px 0;
|
|
163
|
+
color: var(--color-text-muted);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
.article ul, .article ol {
|
|
167
|
+
margin: 0 0 16px 0;
|
|
168
|
+
padding-left: 24px;
|
|
169
|
+
color: var(--color-text-muted);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
.article li {
|
|
173
|
+
margin: 8px 0;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
.article code {
|
|
177
|
+
font-family: var(--font-mono);
|
|
178
|
+
font-size: 0.9em;
|
|
179
|
+
background-color: var(--color-code-bg);
|
|
180
|
+
padding: 2px 6px;
|
|
181
|
+
border-radius: 4px;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
.article pre {
|
|
185
|
+
background-color: var(--color-code-bg);
|
|
186
|
+
border-radius: 8px;
|
|
187
|
+
padding: 16px;
|
|
188
|
+
overflow-x: auto;
|
|
189
|
+
margin: 0 0 24px 0;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
.article pre code {
|
|
193
|
+
background: none;
|
|
194
|
+
padding: 0;
|
|
195
|
+
font-size: 0.875rem;
|
|
196
|
+
line-height: 1.7;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
.article blockquote {
|
|
200
|
+
margin: 0 0 16px 0;
|
|
201
|
+
padding: 12px 16px;
|
|
202
|
+
border-left: 3px solid var(--color-primary);
|
|
203
|
+
background-color: var(--color-bg-secondary);
|
|
204
|
+
border-radius: 0 8px 8px 0;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
.article blockquote p {
|
|
208
|
+
margin: 0;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/* Code highlighting (Rouge) */
|
|
212
|
+
.highlight .k, .highlight .kd, .highlight .kn, .highlight .kp, .highlight .kr, .highlight .kt { color: #c678dd; }
|
|
213
|
+
.highlight .s, .highlight .s1, .highlight .s2, .highlight .sr { color: #98c379; }
|
|
214
|
+
.highlight .c, .highlight .c1, .highlight .cm { color: #5c6370; font-style: italic; }
|
|
215
|
+
.highlight .n, .highlight .nf, .highlight .nb { color: #61afef; }
|
|
216
|
+
.highlight .nc, .highlight .no { color: #e5c07b; }
|
|
217
|
+
.highlight .o, .highlight .p { color: #abb2bf; }
|
|
218
|
+
.highlight .mi, .highlight .mf { color: #d19a66; }
|
|
219
|
+
.highlight .ss { color: #56b6c2; }
|
|
220
|
+
|
|
221
|
+
/* Responsive */
|
|
222
|
+
@media (max-width: 768px) {
|
|
223
|
+
.layout {
|
|
224
|
+
grid-template-columns: 1fr;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
.sidebar {
|
|
228
|
+
display: none;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
.content {
|
|
232
|
+
padding: 24px;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
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.0">
|
|
6
|
+
<title><%= title %> - FunApi</title>
|
|
7
|
+
<link rel="stylesheet" href="/css/style.css">
|
|
8
|
+
</head>
|
|
9
|
+
<body>
|
|
10
|
+
<div class="layout">
|
|
11
|
+
<header class="header">
|
|
12
|
+
<a href="/" class="logo">FunApi</a>
|
|
13
|
+
<nav class="header-nav">
|
|
14
|
+
<a href="/docs/getting-started/quick-start">Docs</a>
|
|
15
|
+
<a href="https://github.com/gafemoyano/fun_api" target="_blank">GitHub</a>
|
|
16
|
+
</nav>
|
|
17
|
+
</header>
|
|
18
|
+
|
|
19
|
+
<aside class="sidebar">
|
|
20
|
+
<%= render_partial("partials/_nav.html.erb", nav: nav, current_path: current_path) %>
|
|
21
|
+
</aside>
|
|
22
|
+
|
|
23
|
+
<main class="content">
|
|
24
|
+
<%= yield_content %>
|
|
25
|
+
</main>
|
|
26
|
+
</div>
|
|
27
|
+
</body>
|
|
28
|
+
</html>
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
<nav class="nav">
|
|
2
|
+
<% nav.each do |section| %>
|
|
3
|
+
<div class="nav-section">
|
|
4
|
+
<h3 class="nav-section-title"><%= section[:title] %></h3>
|
|
5
|
+
<ul class="nav-list">
|
|
6
|
+
<% section[:items].each do |item| %>
|
|
7
|
+
<li class="nav-item">
|
|
8
|
+
<a
|
|
9
|
+
href="/docs/<%= item[:path] %>"
|
|
10
|
+
class="nav-link<%= ' active' if current_path == item[:path] %>"
|
|
11
|
+
>
|
|
12
|
+
<%= item[:title] %>
|
|
13
|
+
</a>
|
|
14
|
+
</li>
|
|
15
|
+
<% end %>
|
|
16
|
+
</ul>
|
|
17
|
+
</div>
|
|
18
|
+
<% end %>
|
|
19
|
+
</nav>
|