steppe 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/.rspec +3 -0
- data/.ruby-version +1 -0
- data/CHANGELOG.md +5 -0
- data/CLAUDE.md +88 -0
- data/LICENSE.txt +21 -0
- data/README.md +883 -0
- data/Rakefile +23 -0
- data/docs/README.md +3 -0
- data/docs/styles.css +527 -0
- data/examples/hanami.ru +29 -0
- data/examples/service.rb +323 -0
- data/examples/sinatra.rb +38 -0
- data/lib/docs_builder.rb +253 -0
- data/lib/steppe/auth/basic.rb +130 -0
- data/lib/steppe/auth/bearer.rb +130 -0
- data/lib/steppe/auth.rb +46 -0
- data/lib/steppe/content_type.rb +80 -0
- data/lib/steppe/endpoint.rb +742 -0
- data/lib/steppe/openapi_visitor.rb +155 -0
- data/lib/steppe/request.rb +22 -0
- data/lib/steppe/responder.rb +165 -0
- data/lib/steppe/responder_registry.rb +79 -0
- data/lib/steppe/result.rb +68 -0
- data/lib/steppe/serializer.rb +180 -0
- data/lib/steppe/service.rb +232 -0
- data/lib/steppe/status_map.rb +82 -0
- data/lib/steppe/utils.rb +19 -0
- data/lib/steppe/version.rb +5 -0
- data/lib/steppe.rb +44 -0
- data/sig/steppe.rbs +4 -0
- metadata +143 -0
data/examples/service.rb
ADDED
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'steppe'
|
|
4
|
+
|
|
5
|
+
class User
|
|
6
|
+
Record = Data.define(:id, :name, :age, :email, :address)
|
|
7
|
+
|
|
8
|
+
class << self
|
|
9
|
+
def data
|
|
10
|
+
@data ||= [
|
|
11
|
+
Record.new(1, 'Alice', 30, 'alice@server.com', '123 Great St'),
|
|
12
|
+
Record.new(2, 'Bob', 25, 'bob@server.com', '23 Long Ave.'),
|
|
13
|
+
Record.new(3, 'Bill', 20, 'bill@server.com', "Bill's Mansion")
|
|
14
|
+
]
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def filter_by_name(name)
|
|
18
|
+
return data unless name
|
|
19
|
+
|
|
20
|
+
data.select { |u| u.name.downcase.start_with?(name.downcase) }
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def find(id)
|
|
24
|
+
data.find { |u| u.id == id }
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def update(id, attrs)
|
|
28
|
+
attrs.delete(:id)
|
|
29
|
+
user = find(id)
|
|
30
|
+
return unless user
|
|
31
|
+
|
|
32
|
+
idx = data.index(user)
|
|
33
|
+
user = user.with(**attrs)
|
|
34
|
+
data[idx] = user
|
|
35
|
+
user
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def create(attrs)
|
|
39
|
+
rec = Record.new(
|
|
40
|
+
id: data.size + 1,
|
|
41
|
+
name: attrs[:name],
|
|
42
|
+
age: attrs[:age],
|
|
43
|
+
email: attrs[:email],
|
|
44
|
+
address: attrs[:address]
|
|
45
|
+
)
|
|
46
|
+
data << rec
|
|
47
|
+
rec
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
module Types
|
|
53
|
+
include Plumb::Types
|
|
54
|
+
|
|
55
|
+
UserCategory = String
|
|
56
|
+
.options(%w[any admin customer guest])
|
|
57
|
+
.default('any')
|
|
58
|
+
.desc('search by category')
|
|
59
|
+
|
|
60
|
+
DowncaseString = String.invoke(:downcase)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
class UserSerializer < Steppe::Serializer
|
|
64
|
+
attribute :id, Types::Integer.example(1)
|
|
65
|
+
attribute :name, Types::String.example('Alice')
|
|
66
|
+
attribute :age, Types::Integer.example('34')
|
|
67
|
+
attribute :email, Types::String.example('alice@server.com')
|
|
68
|
+
attribute? :address, String
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
class HashTokenStore
|
|
72
|
+
def initialize(hash)
|
|
73
|
+
@hash = hash
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def set(claims)
|
|
77
|
+
key = SecureRandom.hex
|
|
78
|
+
@hash[key] = claims.each_with_object({}) { |(k, v), h| h[k.to_s] = v }
|
|
79
|
+
key
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def get(key)
|
|
83
|
+
@hash[key]
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
class BooticAuth
|
|
88
|
+
attr_reader :scopes, :type, :description, :store
|
|
89
|
+
|
|
90
|
+
def initialize(authorization_url:, scopes: [], store: {})
|
|
91
|
+
@scopes = scopes
|
|
92
|
+
@type = :oauth2
|
|
93
|
+
@description = 'Bootic Auth'
|
|
94
|
+
@store = store.is_a?(Hash) ? HashTokenStore.new(store) : store
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# bootic = BooticAuth.new(scopes: %w[admin god])
|
|
99
|
+
|
|
100
|
+
Service = Steppe::Service.new do |api|
|
|
101
|
+
api.title = 'Users API'
|
|
102
|
+
api.description = 'API for managing users'
|
|
103
|
+
api.server(
|
|
104
|
+
url: 'http://localhost:9292',
|
|
105
|
+
description: 'prod server'
|
|
106
|
+
)
|
|
107
|
+
api.tag(
|
|
108
|
+
'users',
|
|
109
|
+
description: 'Users operations',
|
|
110
|
+
external_docs: 'https://example.com/docs/users'
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
api.specs('/')
|
|
114
|
+
|
|
115
|
+
# An endpoint to list users
|
|
116
|
+
api.get :users, '/users' do |e|
|
|
117
|
+
e.description = 'List users'
|
|
118
|
+
e.tags = %w[users]
|
|
119
|
+
# Custom steps, authentication, etc.
|
|
120
|
+
e.step do |conn|
|
|
121
|
+
puts conn.request.env['HTTP_AUTHORIZATION'].inspect
|
|
122
|
+
conn
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# Validate and coerce URL parameters
|
|
126
|
+
e.query_schema(
|
|
127
|
+
q?: Types::DowncaseString.desc('search by name, supports partial matches').example('Bil, Jo'),
|
|
128
|
+
cat?: Types::UserCategory
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
# A step with your business logic
|
|
132
|
+
# In this case filtering users by name
|
|
133
|
+
# This step will only run if the params are valid
|
|
134
|
+
e.step do |conn|
|
|
135
|
+
users = User.filter_by_name(conn.params[:q])
|
|
136
|
+
conn.valid users
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
e.json do
|
|
140
|
+
attribute :users, [UserSerializer]
|
|
141
|
+
|
|
142
|
+
def users
|
|
143
|
+
object
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# Or, use a named serializer class
|
|
148
|
+
# e.json 200, UserListSerializer
|
|
149
|
+
#
|
|
150
|
+
# Or, use #respond for more detailed control
|
|
151
|
+
# e.respond 200...300, :json, UserListSerializer
|
|
152
|
+
#
|
|
153
|
+
# Or, expand into a responder block
|
|
154
|
+
# e.respond 200...300, :json do |r|
|
|
155
|
+
# r.description = "A list of users"
|
|
156
|
+
# r.step CustomStep1
|
|
157
|
+
# r.serializer UserListSerializer
|
|
158
|
+
# r.step CustomStep1
|
|
159
|
+
# end
|
|
160
|
+
#
|
|
161
|
+
# Or, register named responder
|
|
162
|
+
# e.respond UserListResponder
|
|
163
|
+
|
|
164
|
+
# Respond with HTML
|
|
165
|
+
e.html do |conn|
|
|
166
|
+
html5 {
|
|
167
|
+
body {
|
|
168
|
+
h1 'Users'
|
|
169
|
+
ul {
|
|
170
|
+
conn.value.each do |user|
|
|
171
|
+
li {
|
|
172
|
+
text "#{user.id}:"
|
|
173
|
+
a(user.name, href: "/users/#{user.id}")
|
|
174
|
+
text " (#{user.age})"
|
|
175
|
+
}
|
|
176
|
+
end
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
UserName = Plumb::Types::String.desc('User name').example('Alice').present
|
|
184
|
+
UserAge = Steppe::Types::Lax::Integer[18..]
|
|
185
|
+
UserEmail = Steppe::Types::Email.desc('User email').example('alice@email.com')
|
|
186
|
+
UserAddress = Steppe::Types::String.desc('User address').example('123 Great St')
|
|
187
|
+
|
|
188
|
+
# A Standalone action class
|
|
189
|
+
# with its own schema
|
|
190
|
+
# FIXME: classes with their own payload_schema
|
|
191
|
+
# don't automatically register body parses, nor actually validate the schema
|
|
192
|
+
# because it's up to them to validate the params
|
|
193
|
+
class UpdateUser
|
|
194
|
+
QUERY_SCHEMA = Plumb::Types::Hash[id: Steppe::Types::Lax::Integer]
|
|
195
|
+
SCHEMA = Plumb::Types::Hash[
|
|
196
|
+
name?: UserName,
|
|
197
|
+
age?: UserAge,
|
|
198
|
+
email?: UserEmail,
|
|
199
|
+
address?: UserAddress
|
|
200
|
+
]
|
|
201
|
+
|
|
202
|
+
def self.query_schema = QUERY_SCHEMA
|
|
203
|
+
def self.payload_schema = SCHEMA
|
|
204
|
+
|
|
205
|
+
def self.call(conn)
|
|
206
|
+
p conn.params.inspect
|
|
207
|
+
return conn
|
|
208
|
+
user = User.update(conn.params[:id], conn.params)
|
|
209
|
+
return conn.invalid(errors: { id: 'User not found' }) unless user
|
|
210
|
+
|
|
211
|
+
conn.valid user
|
|
212
|
+
end
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
# Another standalone action
|
|
216
|
+
# with its own schema
|
|
217
|
+
class ProcessFile
|
|
218
|
+
def self.payload_schema = Plumb::Types::Hash[
|
|
219
|
+
file: Steppe::Types::UploadedFile.with(type: 'text/plain')
|
|
220
|
+
]
|
|
221
|
+
|
|
222
|
+
def self.call(conn)
|
|
223
|
+
# process the uploaded file
|
|
224
|
+
conn
|
|
225
|
+
end
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
api.put :update_user, '/users/:id' do |e|
|
|
229
|
+
e.description = 'Update a user'
|
|
230
|
+
e.tags = %w[users]
|
|
231
|
+
|
|
232
|
+
e.query_schema(
|
|
233
|
+
id: Steppe::Types::Lax::Integer.desc('User ID')
|
|
234
|
+
)
|
|
235
|
+
|
|
236
|
+
# Endpoint will consolidate schemas from steps
|
|
237
|
+
# that respond to .payload_schema
|
|
238
|
+
# e.step UpdateUser
|
|
239
|
+
e.payload_schema(
|
|
240
|
+
name?: UserName,
|
|
241
|
+
age?: UserAge,
|
|
242
|
+
email?: UserEmail,
|
|
243
|
+
address?: UserAddress
|
|
244
|
+
)
|
|
245
|
+
e.step do |conn|
|
|
246
|
+
user = User.update(conn.params[:id], conn.params)
|
|
247
|
+
user ? conn.valid(user) : conn.invalid(errors: { id: 'User not found' })
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
e.json 200, UserSerializer
|
|
251
|
+
|
|
252
|
+
# e.payload_schema(
|
|
253
|
+
# name: Steppe::Types::String.present,
|
|
254
|
+
# age: Steppe::Types::Lax::Integer[18..],
|
|
255
|
+
# file?: Steppe::Types::UploadedFile.with(type: 'text/plain')
|
|
256
|
+
# )
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
api.get :user, '/users/:id' do |e|
|
|
260
|
+
e.description = 'Fetch information for a user, by ID'
|
|
261
|
+
e.tags = %w[users]
|
|
262
|
+
e.query_schema(
|
|
263
|
+
id: Types::Lax::Integer.desc('User ID')
|
|
264
|
+
)
|
|
265
|
+
e.step do |conn|
|
|
266
|
+
user = User.find(conn.params[:id])
|
|
267
|
+
if user
|
|
268
|
+
conn.valid user
|
|
269
|
+
else
|
|
270
|
+
conn.response.status = 404
|
|
271
|
+
conn.invalid(errors: { id: 'User not found' })
|
|
272
|
+
end
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
e.json 200...300, UserSerializer
|
|
276
|
+
|
|
277
|
+
e.html do |conn|
|
|
278
|
+
html5 {
|
|
279
|
+
body {
|
|
280
|
+
a('users', href: '/users')
|
|
281
|
+
h1 'User'
|
|
282
|
+
dl {
|
|
283
|
+
dt 'ID'
|
|
284
|
+
dd conn.value.id
|
|
285
|
+
dt 'name'
|
|
286
|
+
dd conn.value.name
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
end
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
api.post :create_user, '/users' do |e|
|
|
294
|
+
e.tags = %w[users]
|
|
295
|
+
e.description = 'Create a user'
|
|
296
|
+
|
|
297
|
+
# Validate request BODY payload
|
|
298
|
+
# request body is parsed at this point in the pipeline
|
|
299
|
+
e.payload_schema(
|
|
300
|
+
name: UserName,
|
|
301
|
+
age: UserAge,
|
|
302
|
+
email: UserEmail,
|
|
303
|
+
address?: UserAddress
|
|
304
|
+
)
|
|
305
|
+
|
|
306
|
+
# Create a user, only if params above are valid
|
|
307
|
+
e.step do |conn|
|
|
308
|
+
user = User.create(conn.params)
|
|
309
|
+
conn.respond_with(201).valid user
|
|
310
|
+
end
|
|
311
|
+
|
|
312
|
+
# Serialize the user (valid case)
|
|
313
|
+
# status 422 (invalid) will be handled by default responder
|
|
314
|
+
e.json 201, UserSerializer
|
|
315
|
+
|
|
316
|
+
# Or, register a custom responder for 422
|
|
317
|
+
# e.serialize 422 do
|
|
318
|
+
# attribute :errors, Types::Hash
|
|
319
|
+
# private def errors = conn.errors
|
|
320
|
+
# end
|
|
321
|
+
end
|
|
322
|
+
end
|
|
323
|
+
|
data/examples/sinatra.rb
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# From root, run with:
|
|
4
|
+
# bundle exec ruby examples/sinatra.rb -p 4567
|
|
5
|
+
#
|
|
6
|
+
require 'bundler'
|
|
7
|
+
Bundler.setup(:examples, :sinatra)
|
|
8
|
+
|
|
9
|
+
require 'sinatra/base'
|
|
10
|
+
require 'rack/cors'
|
|
11
|
+
require_relative './service'
|
|
12
|
+
|
|
13
|
+
class SinatraRequestWrapper < SimpleDelegator
|
|
14
|
+
def initialize(request, params)
|
|
15
|
+
super(request)
|
|
16
|
+
@steppe_url_params = params
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
attr_reader :steppe_url_params
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
class App < Sinatra::Base
|
|
23
|
+
use Rack::Cors do
|
|
24
|
+
allow do
|
|
25
|
+
origins '*'
|
|
26
|
+
resource '*', headers: :any, methods: :any
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
Service.endpoints.each do |endpoint|
|
|
31
|
+
public_send(endpoint.verb, endpoint.path.to_templates.first) do
|
|
32
|
+
resp = endpoint.run(SinatraRequestWrapper.new(request, params)).response
|
|
33
|
+
resp.finish
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
run! if 'examples/sinatra.rb' == $0
|
|
38
|
+
end
|
data/lib/docs_builder.rb
ADDED
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require 'kramdown'
|
|
5
|
+
require 'fileutils'
|
|
6
|
+
|
|
7
|
+
class DocsBuilder
|
|
8
|
+
GITHUB_URL = 'https://github.com/ismasan/steppe'
|
|
9
|
+
|
|
10
|
+
def initialize(readme_path:, output_dir:)
|
|
11
|
+
@readme_path = readme_path
|
|
12
|
+
@output_dir = output_dir
|
|
13
|
+
@sections = []
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def build
|
|
17
|
+
puts "Reading #{@readme_path}..."
|
|
18
|
+
markdown = File.read(@readme_path)
|
|
19
|
+
|
|
20
|
+
puts "Parsing markdown..."
|
|
21
|
+
doc = Kramdown::Document.new(markdown, input: 'GFM', auto_ids: true)
|
|
22
|
+
|
|
23
|
+
puts "Extracting structure..."
|
|
24
|
+
extract_structure(doc.root)
|
|
25
|
+
|
|
26
|
+
puts "Generating HTML..."
|
|
27
|
+
html = generate_html(doc)
|
|
28
|
+
|
|
29
|
+
puts "Writing to #{@output_dir}/index.html..."
|
|
30
|
+
FileUtils.mkdir_p(@output_dir)
|
|
31
|
+
File.write(File.join(@output_dir, 'index.html'), html)
|
|
32
|
+
|
|
33
|
+
puts "Done! Documentation built successfully."
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
private
|
|
37
|
+
|
|
38
|
+
def extract_structure(element, level = 0)
|
|
39
|
+
element.children.each do |child|
|
|
40
|
+
if child.type == :header
|
|
41
|
+
# Use Kramdown's auto-generated ID
|
|
42
|
+
id = child.attr['id']
|
|
43
|
+
title = extract_text(child)
|
|
44
|
+
|
|
45
|
+
@sections << {
|
|
46
|
+
level: child.options[:level],
|
|
47
|
+
id: id,
|
|
48
|
+
title: title,
|
|
49
|
+
element: child
|
|
50
|
+
}
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
extract_structure(child, level + 1) if child.children
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def extract_text(element)
|
|
58
|
+
case element.type
|
|
59
|
+
when :text
|
|
60
|
+
element.value
|
|
61
|
+
when :codespan
|
|
62
|
+
element.value
|
|
63
|
+
when :header, :p, :strong, :em
|
|
64
|
+
element.children.map { |c| extract_text(c) }.join if element.children
|
|
65
|
+
else
|
|
66
|
+
if element.children && !element.children.empty?
|
|
67
|
+
element.children.map { |c| extract_text(c) }.join
|
|
68
|
+
else
|
|
69
|
+
''
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def generate_navigation
|
|
75
|
+
nav_items = []
|
|
76
|
+
current_section = nil
|
|
77
|
+
|
|
78
|
+
@sections.each do |section|
|
|
79
|
+
if section[:level] == 2
|
|
80
|
+
current_section = section
|
|
81
|
+
nav_items << %(<li><a href="##{section[:id]}">#{section[:title]}</a></li>)
|
|
82
|
+
elsif section[:level] == 3 && current_section
|
|
83
|
+
nav_items << %(<li class="nav-submenu"><a href="##{section[:id]}">#{section[:title]}</a></li>)
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
nav_items.join("\n ")
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def generate_html(doc)
|
|
91
|
+
# Convert markdown to HTML
|
|
92
|
+
content_html = doc.to_html
|
|
93
|
+
|
|
94
|
+
# Process the HTML to add proper structure
|
|
95
|
+
content_html = wrap_sections(content_html)
|
|
96
|
+
|
|
97
|
+
<<~HTML
|
|
98
|
+
<!DOCTYPE html>
|
|
99
|
+
<html lang="en">
|
|
100
|
+
<head>
|
|
101
|
+
<meta charset="UTF-8">
|
|
102
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
103
|
+
<title>Steppe - Composable, self-documenting REST APIs for Ruby</title>
|
|
104
|
+
<link rel="stylesheet" href="styles.css">
|
|
105
|
+
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/atom-one-dark.min.css">
|
|
106
|
+
</head>
|
|
107
|
+
<body>
|
|
108
|
+
<nav class="top-menu">
|
|
109
|
+
<div class="top-menu-content">
|
|
110
|
+
<div class="top-menu-brand">
|
|
111
|
+
<span class="brand-name">Steppe</span>
|
|
112
|
+
<span class="brand-tagline">Composable REST APIs for Ruby</span>
|
|
113
|
+
</div>
|
|
114
|
+
<a href="#{GITHUB_URL}" target="_blank" class="github-link" aria-label="View on GitHub">
|
|
115
|
+
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
|
|
116
|
+
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/>
|
|
117
|
+
</svg>
|
|
118
|
+
<span>GitHub</span>
|
|
119
|
+
</a>
|
|
120
|
+
</div>
|
|
121
|
+
</nav>
|
|
122
|
+
<div class="container">
|
|
123
|
+
<nav class="sidebar">
|
|
124
|
+
<div class="logo">
|
|
125
|
+
<h2>Steppe</h2>
|
|
126
|
+
<p class="tagline">REST APIs for Ruby</p>
|
|
127
|
+
</div>
|
|
128
|
+
<ul class="nav-menu">
|
|
129
|
+
#{generate_navigation}
|
|
130
|
+
</ul>
|
|
131
|
+
</nav>
|
|
132
|
+
|
|
133
|
+
<main class="content">
|
|
134
|
+
<header class="page-header">
|
|
135
|
+
<h1>Steppe</h1>
|
|
136
|
+
<p class="subtitle">Composable, self-documenting REST APIs for Ruby</p>
|
|
137
|
+
</header>
|
|
138
|
+
|
|
139
|
+
#{content_html}
|
|
140
|
+
</main>
|
|
141
|
+
</div>
|
|
142
|
+
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js"></script>
|
|
143
|
+
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/languages/ruby.min.js"></script>
|
|
144
|
+
<script>hljs.highlightAll();</script>
|
|
145
|
+
<script>
|
|
146
|
+
// Active section highlighting
|
|
147
|
+
const observerOptions = {
|
|
148
|
+
root: null,
|
|
149
|
+
rootMargin: '-20% 0px -60% 0px',
|
|
150
|
+
threshold: 0
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
const sections = document.querySelectorAll('section, article[id]');
|
|
154
|
+
const navLinks = document.querySelectorAll('.nav-menu a');
|
|
155
|
+
|
|
156
|
+
// Create a map of href to link elements
|
|
157
|
+
const linkMap = new Map();
|
|
158
|
+
navLinks.forEach(link => {
|
|
159
|
+
const href = link.getAttribute('href');
|
|
160
|
+
if (href && href.startsWith('#')) {
|
|
161
|
+
linkMap.set(href, link);
|
|
162
|
+
}
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
const observer = new IntersectionObserver((entries) => {
|
|
166
|
+
entries.forEach(entry => {
|
|
167
|
+
if (entry.isIntersecting) {
|
|
168
|
+
const id = entry.target.getAttribute('id');
|
|
169
|
+
const activeLink = linkMap.get(`#${id}`);
|
|
170
|
+
|
|
171
|
+
if (activeLink) {
|
|
172
|
+
// Remove active class from all links
|
|
173
|
+
navLinks.forEach(link => link.classList.remove('active'));
|
|
174
|
+
// Add active class to current link
|
|
175
|
+
activeLink.classList.add('active');
|
|
176
|
+
|
|
177
|
+
// Update URL hash without scrolling
|
|
178
|
+
if (history.replaceState) {
|
|
179
|
+
history.replaceState(null, null, `#${id}`);
|
|
180
|
+
} else {
|
|
181
|
+
window.location.hash = id;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
});
|
|
186
|
+
}, observerOptions);
|
|
187
|
+
|
|
188
|
+
// Observe all sections
|
|
189
|
+
sections.forEach(section => {
|
|
190
|
+
if (section.id) {
|
|
191
|
+
observer.observe(section);
|
|
192
|
+
}
|
|
193
|
+
});
|
|
194
|
+
</script>
|
|
195
|
+
</body>
|
|
196
|
+
</html>
|
|
197
|
+
HTML
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
def wrap_sections(html)
|
|
201
|
+
# Remove the first h1 (title) as it's in the page header
|
|
202
|
+
html = html.sub(/<h1[^>]*>.*?<\/h1>/, '')
|
|
203
|
+
|
|
204
|
+
# Wrap h2 sections
|
|
205
|
+
html.gsub!(/<h2 id="([^"]+)">(.+?)<\/h2>/) do
|
|
206
|
+
id = $1
|
|
207
|
+
title = $2
|
|
208
|
+
if id == 'usage'
|
|
209
|
+
%(</section>\n\n<section id="#{id}" class="section">\n<h2 id="#{id}">#{title}</h2>)
|
|
210
|
+
else
|
|
211
|
+
%(<section id="#{id}" class="section">\n<h2 id="#{id}">#{title}</h2>)
|
|
212
|
+
end
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
# Wrap h3 subsections
|
|
216
|
+
html.gsub!(/<h3 id="([^"]+)">(.+?)<\/h3>/) do
|
|
217
|
+
id = $1
|
|
218
|
+
title = $2
|
|
219
|
+
%(</article>\n\n<article id="#{id}" class="subsection">\n<h3 id="#{id}">#{title}</h3>)
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
# Close any remaining open sections
|
|
223
|
+
html += "\n</article>\n</section>" if html.include?('<section') || html.include?('<article')
|
|
224
|
+
|
|
225
|
+
# Wrap first section (Overview)
|
|
226
|
+
html = "<section id=\"overview\" class=\"section\">\n" + html
|
|
227
|
+
|
|
228
|
+
# Clean up multiple closing tags
|
|
229
|
+
html.gsub!(/(<\/article>\s*){2,}/, '</article>')
|
|
230
|
+
html.gsub!(/(<\/section>\s*){2,}/, '</section>')
|
|
231
|
+
|
|
232
|
+
html
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
# Run the builder
|
|
238
|
+
if __FILE__ == $0
|
|
239
|
+
readme_path = ARGV[0] || 'README.md'
|
|
240
|
+
output_dir = ARGV[1] || 'website'
|
|
241
|
+
|
|
242
|
+
unless File.exist?(readme_path)
|
|
243
|
+
puts "Error: README file not found at #{readme_path}"
|
|
244
|
+
exit 1
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
builder = DocsBuilder.new(
|
|
248
|
+
readme_path: readme_path,
|
|
249
|
+
output_dir: output_dir
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
builder.build
|
|
253
|
+
end
|