rails-api-docs 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.

Potentially problematic release.


This version of rails-api-docs might be problematic. Click here for more details.

@@ -0,0 +1,695 @@
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><%= h(general["title"]) %></title>
7
+ <style>
8
+ :root {
9
+ --primary: <%= h(general["primary_color"]) %>;
10
+ --primary-soft: color-mix(in srgb, <%= h(general["primary_color"]) %> 8%, transparent);
11
+ --secondary: <%= h(general["secondary_color"]) %>;
12
+ --accent: <%= h(general["accent_color"] || general["primary_color"]) %>;
13
+ --bg: #FFFFFF;
14
+ --sidebar-bg: #FAFAFA;
15
+ --border: #EAEAEA;
16
+ --border-strong: #D4D4D4;
17
+ --text: #1F1F1F;
18
+ --text-muted: #6B7280;
19
+ --text-soft: #9CA3AF;
20
+
21
+ --code-bg: #1B1B1F;
22
+ --code-text: #E5E5E5;
23
+ --code-line: #4B5563;
24
+
25
+ --verb-get: #2563EB;
26
+ --verb-post: #16A34A;
27
+ --verb-put: #D97706;
28
+ --verb-patch: #D97706;
29
+ --verb-delete: #DC2626;
30
+
31
+ --verb-get-dark: #60A5FA;
32
+ --verb-post-dark: #4ADE80;
33
+ --verb-put-dark: #FBBF24;
34
+ --verb-patch-dark: #FBBF24;
35
+ --verb-delete-dark: #F87171;
36
+ }
37
+
38
+ * { margin: 0; padding: 0; box-sizing: border-box; }
39
+ html, body { height: 100%; }
40
+ body {
41
+ font-family: <%= h(general["font_family"]) %>;
42
+ color: var(--text); background: var(--bg);
43
+ font-size: 14px; line-height: 1.55;
44
+ -webkit-font-smoothing: antialiased;
45
+ }
46
+ a { color: inherit; text-decoration: none; }
47
+ code, pre, .mono { font-family: ui-monospace, "SF Mono", SFMono-Regular, Menlo, Monaco, Consolas, monospace; }
48
+
49
+ .topbar {
50
+ height: 56px; border-bottom: 1px solid var(--border);
51
+ display: flex; align-items: center; padding: 0 24px;
52
+ position: sticky; top: 0; background: var(--bg); z-index: 10;
53
+ }
54
+ .brand { display: flex; align-items: center; gap: 10px; font-weight: 600; font-size: 15px; }
55
+ .brand-logo {
56
+ width: 26px; height: 26px; background: var(--primary); border-radius: 50%;
57
+ display: inline-flex; align-items: center; justify-content: center;
58
+ color: white; font-weight: 700; font-size: 13px; font-family: ui-monospace, monospace;
59
+ }
60
+ .spacer { flex: 1; }
61
+
62
+ .container { display: grid; grid-template-columns: 280px minmax(0, 1fr) 500px; align-items: start; }
63
+
64
+ .sidebar {
65
+ background: var(--sidebar-bg); border-right: 1px solid var(--border);
66
+ padding: 16px 12px 32px; position: sticky; top: 56px;
67
+ height: calc(100vh - 56px); overflow-y: auto;
68
+ }
69
+ .search {
70
+ display: flex; align-items: center; gap: 8px; padding: 8px 12px;
71
+ background: var(--bg); border: 1px solid var(--border); border-radius: 8px;
72
+ margin-bottom: 18px; color: var(--text-muted); font-size: 13px;
73
+ }
74
+ .search .kbd {
75
+ margin-left: auto; background: var(--sidebar-bg); border: 1px solid var(--border);
76
+ border-radius: 4px; padding: 1px 6px; font-size: 11px;
77
+ font-family: ui-monospace, monospace; color: var(--text-muted);
78
+ }
79
+ .search input {
80
+ border: none; outline: none; background: transparent; flex: 1;
81
+ font: inherit; color: var(--text);
82
+ }
83
+ .search input::placeholder { color: var(--text-muted); }
84
+
85
+ .section { margin-bottom: 6px; }
86
+ .section-header {
87
+ display: flex; align-items: center; gap: 8px; padding: 7px 10px;
88
+ font-weight: 600; font-size: 13px; color: var(--text);
89
+ border-radius: 6px; user-select: none;
90
+ }
91
+ .endpoints { list-style: none; padding: 0; }
92
+ .endpoints a {
93
+ display: flex; align-items: center; justify-content: space-between; gap: 10px;
94
+ padding: 6px 10px 6px 22px; border-radius: 6px; font-size: 13px;
95
+ color: var(--text); margin-bottom: 1px;
96
+ }
97
+ .endpoints a:hover { background: rgba(0,0,0,0.04); }
98
+ .endpoints a.active { background: var(--primary-soft); color: var(--primary); font-weight: 500; }
99
+ .endpoints a .label { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
100
+
101
+ .verb {
102
+ font-family: ui-monospace, monospace; font-size: 10px; font-weight: 700;
103
+ letter-spacing: 0.04em; text-transform: uppercase; flex-shrink: 0;
104
+ }
105
+ .verb-get { color: var(--verb-get); }
106
+ .verb-post { color: var(--verb-post); }
107
+ .verb-put { color: var(--verb-put); }
108
+ .verb-patch { color: var(--verb-patch); }
109
+ .verb-delete { color: var(--verb-delete); }
110
+
111
+ .main { padding: 40px 56px 80px; max-width: 760px; width: 100%; }
112
+
113
+ .endpoint-page { display: none; }
114
+ .endpoint-page.active { display: block; }
115
+
116
+ .endpoint-path {
117
+ display: flex; align-items: center; gap: 8px;
118
+ width: fit-content; max-width: 100%;
119
+ background: var(--sidebar-bg); border: 1px solid var(--border);
120
+ border-radius: 999px; padding: 4px 12px 4px 4px; font-size: 12px;
121
+ color: var(--text-muted); margin-bottom: 14px;
122
+ }
123
+ .endpoint-path .verb-pill {
124
+ padding: 3px 10px; border-radius: 999px; font-size: 11px;
125
+ font-family: ui-monospace, monospace; font-weight: 700;
126
+ }
127
+ .endpoint-path .verb-pill.verb-get { background: rgba(37,99,235,0.12); color: var(--verb-get); }
128
+ .endpoint-path .verb-pill.verb-post { background: rgba(22,163,74,0.12); color: var(--verb-post); }
129
+ .endpoint-path .verb-pill.verb-put,
130
+ .endpoint-path .verb-pill.verb-patch { background: rgba(217,119,6,0.12); color: var(--verb-put); }
131
+ .endpoint-path .verb-pill.verb-delete { background: rgba(220,38,38,0.12); color: var(--verb-delete); }
132
+ .endpoint-path .path {
133
+ font-family: ui-monospace, monospace; color: var(--text); font-size: 12.5px;
134
+ overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0;
135
+ }
136
+
137
+ .main h1 { font-size: 28px; font-weight: 700; line-height: 1.2; margin-bottom: 10px; letter-spacing: -0.01em; }
138
+ .main .lead { font-size: 15px; color: var(--text); margin-bottom: 32px; max-width: 620px; }
139
+ .main h2 {
140
+ font-size: 19px; font-weight: 600; padding-bottom: 10px;
141
+ border-bottom: 1px solid var(--border); margin: 36px 0 18px;
142
+ letter-spacing: -0.01em;
143
+ }
144
+ .main h2:first-of-type { margin-top: 0; }
145
+
146
+ .field { padding: 14px 0; border-bottom: 1px solid var(--border); }
147
+ .field:last-child { border-bottom: none; }
148
+ .field-row { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
149
+ .field-name { font-family: ui-monospace, monospace; font-weight: 600; font-size: 14px; color: var(--text); }
150
+ .field-type { color: var(--text-muted); font-size: 13px; }
151
+ .field-badge {
152
+ font-size: 11px; padding: 1px 7px; border-radius: 4px;
153
+ font-weight: 500; letter-spacing: 0.01em;
154
+ }
155
+ .field-badge.required { background: #FEF3C7; color: #92400E; }
156
+ .field-badge.in-path { background: #E0E7FF; color: #3730A3; }
157
+ .field-badge.format { background: #ECFCCB; color: #3F6212; }
158
+ .field-badge.readonly { background: #DBEAFE; color: #1E40AF; }
159
+ .field-badge.writeonly { background: #FEE2E2; color: #991B1B; }
160
+ .field-badge.nullable { background: #F3F4F6; color: #4B5563; font-style: italic; }
161
+ .field-meta {
162
+ font-size: 11px; font-family: ui-monospace, monospace;
163
+ color: var(--text-soft); padding: 0 2px;
164
+ }
165
+ .field-meta.mono { color: var(--text-muted); }
166
+ .field-desc { color: var(--text-muted); font-size: 13px; margin-top: 6px; line-height: 1.5; }
167
+ .field-enum { color: var(--text-muted); font-size: 12.5px; margin-top: 4px; }
168
+ .field-enum code {
169
+ background: var(--sidebar-bg); border: 1px solid var(--border);
170
+ padding: 1px 6px; border-radius: 4px; font-size: 11.5px;
171
+ }
172
+ .field-example { color: var(--text-muted); font-size: 12.5px; margin-top: 4px; }
173
+ .field-example code {
174
+ background: var(--sidebar-bg); border: 1px solid var(--border);
175
+ padding: 1px 6px; border-radius: 4px; font-size: 11.5px;
176
+ }
177
+
178
+ .endpoint-meta {
179
+ font-size: 11px; padding: 2px 8px; border-radius: 999px;
180
+ font-weight: 500; margin-left: 4px;
181
+ }
182
+ .endpoint-meta.deprecated { background: #FEE2E2; color: #991B1B; font-weight: 600; }
183
+ .endpoint-meta.auth { background: #1F2937; color: #E5E7EB; font-family: ui-monospace, monospace; }
184
+ .endpoint-meta.tag {
185
+ background: var(--sidebar-bg); color: var(--text-muted); border: 1px solid var(--border);
186
+ cursor: pointer; font-family: inherit; font-size: 11px;
187
+ padding: 2px 8px; border-radius: 999px; font-weight: 500;
188
+ transition: background 120ms ease, color 120ms ease, border-color 120ms ease;
189
+ }
190
+ .endpoint-meta.tag:hover { background: var(--border); color: var(--text); }
191
+ .endpoint-meta.tag.active {
192
+ background: var(--primary-soft); color: var(--primary); border-color: var(--primary);
193
+ }
194
+ .endpoint-meta.tag:focus-visible {
195
+ outline: 2px solid var(--primary); outline-offset: 1px;
196
+ }
197
+
198
+ .active-tag-filter {
199
+ display: flex; align-items: center; gap: 6px;
200
+ margin: 0 4px 14px;
201
+ padding: 6px 8px 6px 12px;
202
+ background: var(--primary-soft); border: 1px solid var(--primary);
203
+ border-radius: 8px;
204
+ font-size: 12px; color: var(--primary);
205
+ }
206
+ .active-tag-filter .label { font-weight: 500; }
207
+ .active-tag-filter .tag-name {
208
+ font-family: ui-monospace, monospace; font-weight: 600;
209
+ flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
210
+ }
211
+ .active-tag-filter .clear-filter {
212
+ background: transparent; border: none; cursor: pointer;
213
+ color: var(--primary); font-size: 16px; line-height: 1;
214
+ padding: 0 4px; border-radius: 4px;
215
+ }
216
+ .active-tag-filter .clear-filter:hover { background: rgba(204,0,0,0.12); }
217
+ .active-tag-filter .clear-filter:focus-visible {
218
+ outline: 2px solid var(--primary); outline-offset: 1px;
219
+ }
220
+
221
+ .main h1.deprecated {
222
+ text-decoration: line-through;
223
+ text-decoration-color: rgba(220, 38, 38, 0.5);
224
+ text-decoration-thickness: 2px;
225
+ color: var(--text-muted);
226
+ }
227
+
228
+ .response-block {
229
+ margin: 20px 0 28px; padding: 16px 18px;
230
+ background: var(--sidebar-bg); border: 1px solid var(--border);
231
+ border-radius: 8px;
232
+ }
233
+ .response-block-header {
234
+ display: flex; align-items: center; gap: 12px;
235
+ margin-bottom: 6px;
236
+ }
237
+ .response-status {
238
+ font-family: ui-monospace, monospace; font-weight: 700; font-size: 13px;
239
+ }
240
+ .response-status.status-2xx { color: #16A34A; }
241
+ .response-status.status-3xx { color: #0EA5E9; }
242
+ .response-status.status-4xx { color: #D97706; }
243
+ .response-status.status-5xx { color: #DC2626; }
244
+ .response-block-desc { color: var(--text); font-size: 14px; }
245
+ .response-subhead {
246
+ font-size: 13px; font-weight: 600; color: var(--text-muted);
247
+ text-transform: uppercase; letter-spacing: 0.04em;
248
+ margin: 14px 0 4px; padding: 0;
249
+ border-bottom: none;
250
+ }
251
+ .response-block .field:last-of-type { border-bottom: none; }
252
+
253
+ .empty-state {
254
+ padding: 80px 24px; text-align: center; color: var(--text-muted);
255
+ }
256
+ .empty-state h1 { color: var(--text); margin-bottom: 12px; }
257
+
258
+ .code-column {
259
+ padding: 40px 32px 80px 0; position: sticky; top: 56px;
260
+ height: calc(100vh - 56px); overflow-y: auto;
261
+ }
262
+ .code-pane { display: none; flex-direction: column; gap: 16px; }
263
+ .code-pane.active { display: flex; }
264
+
265
+ .code-block {
266
+ background: var(--code-bg); border-radius: 12px; overflow: hidden;
267
+ color: var(--code-text); font-family: ui-monospace, monospace;
268
+ box-shadow: 0 1px 3px rgba(0,0,0,0.05), 0 1px 2px rgba(0,0,0,0.04);
269
+ }
270
+ .code-header {
271
+ display: flex; justify-content: space-between; align-items: center; gap: 12px;
272
+ padding: 11px 14px; border-bottom: 1px solid rgba(255,255,255,0.06);
273
+ font-size: 12.5px;
274
+ }
275
+ .code-header > div {
276
+ display: flex; align-items: center;
277
+ min-width: 0; flex: 1; overflow: hidden;
278
+ }
279
+ .code-header .verb-tag { font-weight: 700; margin-right: 8px; font-size: 11.5px; letter-spacing: 0.04em; flex-shrink: 0; }
280
+ .code-header .verb-tag.verb-get { color: var(--verb-get-dark); }
281
+ .code-header .verb-tag.verb-post { color: var(--verb-post-dark); }
282
+ .code-header .verb-tag.verb-put { color: var(--verb-put-dark); }
283
+ .code-header .verb-tag.verb-patch { color: var(--verb-patch-dark); }
284
+ .code-header .verb-tag.verb-delete { color: var(--verb-delete-dark); }
285
+ .code-header .path {
286
+ color: #E5E5E5; font-size: 12.5px;
287
+ overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0;
288
+ }
289
+ .code-header .lang { color: var(--text-soft); font-size: 11.5px; flex-shrink: 0; }
290
+ .code-header-right {
291
+ display: flex; align-items: center; gap: 10px; flex-shrink: 0;
292
+ }
293
+ .response-title {
294
+ color: #E5E5E5; font-size: 12.5px; font-weight: 500;
295
+ }
296
+ .copy-btn {
297
+ background: transparent; border: none; cursor: pointer;
298
+ padding: 4px; border-radius: 4px;
299
+ display: inline-flex; align-items: center; justify-content: center;
300
+ color: var(--text-soft);
301
+ transition: color 120ms ease, background 120ms ease;
302
+ }
303
+ .copy-btn:hover { color: #E5E5E5; background: rgba(255,255,255,0.08); }
304
+ .copy-btn:active { background: rgba(255,255,255,0.12); }
305
+ .copy-btn:focus-visible {
306
+ outline: 2px solid rgba(255,255,255,0.4); outline-offset: 1px;
307
+ }
308
+ .copy-btn .check-icon { display: none; }
309
+ .copy-btn.copied .copy-icon { display: none; }
310
+ .copy-btn.copied .check-icon { display: inline; color: #4ADE80; }
311
+ .copy-btn.copied { color: #4ADE80; }
312
+
313
+ .response-tabs {
314
+ display: flex; gap: 18px; padding: 12px 16px;
315
+ border-bottom: 1px solid rgba(255,255,255,0.06);
316
+ }
317
+ .response-tab {
318
+ color: var(--text-soft); font-size: 12.5px; font-weight: 500;
319
+ cursor: pointer; padding: 2px 0; font-family: ui-monospace, monospace;
320
+ border-bottom: 1.5px solid transparent;
321
+ }
322
+ .response-tab.active { color: #E5E5E5; border-bottom-color: #E5E5E5; }
323
+ .response-tab.status-2xx.active { color: #4ADE80; border-bottom-color: #4ADE80; }
324
+ .response-tab.status-3xx.active { color: #60A5FA; border-bottom-color: #60A5FA; }
325
+ .response-tab.status-4xx.active { color: #FBBF24; border-bottom-color: #FBBF24; }
326
+ .response-tab.status-5xx.active { color: #F87171; border-bottom-color: #F87171; }
327
+ .response-tab:hover:not(.active) { color: #D1D5DB; }
328
+
329
+ .response-body { display: none; }
330
+ .response-body.active { display: block; }
331
+
332
+ .code-body { display: flex; padding: 12px 0; font-size: 12.5px; line-height: 1.7; overflow-x: auto; }
333
+ .code-line-numbers {
334
+ padding: 0 10px 0 16px; color: var(--code-line); text-align: right;
335
+ user-select: none; border-right: 1px solid rgba(255,255,255,0.06); flex-shrink: 0;
336
+ }
337
+ .code-content { padding: 0 16px; flex: 1; min-width: 0; }
338
+ .code-content pre { margin: 0; white-space: pre; }
339
+
340
+ ::-webkit-scrollbar { width: 8px; height: 8px; }
341
+ ::-webkit-scrollbar-thumb { background: rgba(0,0,0,0.15); border-radius: 4px; }
342
+ ::-webkit-scrollbar-thumb:hover { background: rgba(0,0,0,0.25); }
343
+ .code-block ::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.12); }
344
+ </style>
345
+ </head>
346
+ <body>
347
+
348
+ <header class="topbar">
349
+ <div class="brand">
350
+ <div class="brand-logo"><%= h(general["title"].to_s[0]) %></div>
351
+ <span><%= h(general["title"]) %></span>
352
+ </div>
353
+ <div class="spacer"></div>
354
+ </header>
355
+
356
+ <div class="container">
357
+
358
+ <aside class="sidebar">
359
+ <div class="search">
360
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="7"/><path d="m20 20-3-3"/></svg>
361
+ <input type="search" placeholder="Search endpoints" id="rad-search">
362
+ <span class="kbd">/</span>
363
+ </div>
364
+
365
+ <div class="active-tag-filter" id="rad-active-tag-filter" hidden>
366
+ <span class="label">Filtered by:</span>
367
+ <span class="tag-name" aria-live="polite"></span>
368
+ <button type="button" class="clear-filter" aria-label="Clear tag filter">×</button>
369
+ </div>
370
+
371
+ <% visible_sections.each do |section_key, section| -%>
372
+ <div class="section" data-section="<%= h(section_slug(section_key)) %>">
373
+ <div class="section-header"><%= h(section["name"]) %></div>
374
+ <ul class="endpoints">
375
+ <% section["endpoints"].each do |endpoint| -%>
376
+ <li data-endpoint-label="<%= h(endpoint['name']) %>"<%= sidebar_tags_attr(endpoint) %>>
377
+ <a href="#<%= endpoint_id(section_key, endpoint) %>" data-endpoint="<%= endpoint_id(section_key, endpoint) %>">
378
+ <span class="label"><%= h(endpoint["name"]) %></span>
379
+ <span class="verb <%= verb_class(endpoint['method']) %>"><%= verb_label(endpoint["method"]) %></span>
380
+ </a>
381
+ </li>
382
+ <% end -%>
383
+ </ul>
384
+ </div>
385
+ <% end -%>
386
+ </aside>
387
+
388
+ <main class="main">
389
+ <% if empty? -%>
390
+ <div class="empty-state">
391
+ <h1>No endpoints to show</h1>
392
+ <p>Add routes to your Rails app and run <code>rails g rails-api-docs:init</code> to scaffold the config,
393
+ then edit <code>config/rails-api-docs.yml</code> to set <code>show: true</code> on the endpoints you want here.</p>
394
+ </div>
395
+ <% else -%>
396
+ <% visible_sections.each do |section_key, section| -%>
397
+ <% section["endpoints"].each do |endpoint| -%>
398
+ <section class="endpoint-page" id="<%= endpoint_id(section_key, endpoint) %>" data-endpoint-page="<%= endpoint_id(section_key, endpoint) %>">
399
+ <div class="endpoint-path">
400
+ <span class="verb-pill <%= verb_class(endpoint['method']) %>"><%= verb_label(endpoint["method"]) %></span>
401
+ <span class="path"><%= h(endpoint["path"]) %></span>
402
+ <% if endpoint["deprecated"] -%>
403
+ <span class="endpoint-meta deprecated">Deprecated</span>
404
+ <% end -%>
405
+ <% if endpoint["auth"] && !endpoint["auth"].to_s.empty? -%>
406
+ <span class="endpoint-meta auth">🔒 <%= h(auth_label(endpoint["auth"])) %></span>
407
+ <% end -%>
408
+ <% Array(endpoint["tags"]).each do |tag| -%>
409
+ <button type="button" class="endpoint-meta tag" data-tag-filter="<%= h(tag) %>" aria-pressed="false"><%= h(tag) %></button>
410
+ <% end -%>
411
+ </div>
412
+ <% if endpoint["deprecated"] -%>
413
+ <h1 class="deprecated"><%= h(endpoint["name"]) %></h1>
414
+ <% else -%>
415
+ <h1><%= h(endpoint["name"]) %></h1>
416
+ <% end -%>
417
+ <% if endpoint["description"] && !endpoint["description"].to_s.empty? -%>
418
+ <p class="lead"><%= h(endpoint["description"]) %></p>
419
+ <% end -%>
420
+
421
+ <% if headers_present?(endpoint) -%>
422
+ <h2>Headers</h2>
423
+ <%= render_fields(endpoint["headers"]) %>
424
+ <% end -%>
425
+
426
+ <% if params_present?(endpoint) -%>
427
+ <h2>Params</h2>
428
+ <%= render_fields(endpoint["params"]) %>
429
+ <% end -%>
430
+
431
+ <% if body_present?(endpoint) -%>
432
+ <h2>Body</h2>
433
+ <%= render_fields(endpoint["body"]) %>
434
+ <% end -%>
435
+
436
+ <% if responses_have_details?(endpoint) -%>
437
+ <h2>Responses</h2>
438
+ <% responses_for(endpoint).each do |resp| -%>
439
+ <div class="response-block">
440
+ <div class="response-block-header">
441
+ <span class="response-status <%= status_class(resp['status']) %>"><%= h(resp["status"]) %></span>
442
+ <% if resp["description"] && !resp["description"].empty? -%>
443
+ <span class="response-block-desc"><%= h(resp["description"]) %></span>
444
+ <% end -%>
445
+ </div>
446
+ <% if resp["headers"].any? -%>
447
+ <h3 class="response-subhead">Headers</h3>
448
+ <%= render_fields(resp["headers"]) %>
449
+ <% end -%>
450
+ <% if resp["schema"].any? -%>
451
+ <h3 class="response-subhead">Schema</h3>
452
+ <%= render_fields(resp["schema"]) %>
453
+ <% end -%>
454
+ </div>
455
+ <% end -%>
456
+ <% end -%>
457
+ </section>
458
+ <% end -%>
459
+ <% end -%>
460
+ <% end -%>
461
+ </main>
462
+
463
+ <aside class="code-column">
464
+ <% visible_sections.each do |section_key, section| -%>
465
+ <% section["endpoints"].each do |endpoint| -%>
466
+ <% if general["show_curl"] || general["show_examples"] -%>
467
+ <div class="code-pane" data-code-pane="<%= endpoint_id(section_key, endpoint) %>">
468
+ <% if general["show_curl"] -%>
469
+ <div class="code-block">
470
+ <div class="code-header">
471
+ <div>
472
+ <span class="verb-tag <%= verb_class(endpoint['method']) %>"><%= verb_label(endpoint["method"]) %></span>
473
+ <span class="path"><%= h(endpoint["path"]) %></span>
474
+ </div>
475
+ <div class="code-header-right">
476
+ <span class="lang">Shell · cURL</span>
477
+ <button type="button" class="copy-btn" aria-label="Copy to clipboard">
478
+ <svg class="copy-icon" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>
479
+ <svg class="check-icon" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
480
+ </button>
481
+ </div>
482
+ </div>
483
+ <%
484
+ curl = curl_for(endpoint)
485
+ lines = curl.lines
486
+ count = lines.size
487
+ -%>
488
+ <div class="code-body">
489
+ <div class="code-line-numbers"><pre><%= (1..count).to_a.join("\n") %></pre></div>
490
+ <div class="code-content"><pre><%= h(curl) %></pre></div>
491
+ </div>
492
+ </div>
493
+ <% end -%>
494
+
495
+ <% if general["show_examples"] -%>
496
+ <%
497
+ responses = responses_for(endpoint)
498
+ response_uid = endpoint_id(section_key, endpoint)
499
+ -%>
500
+ <div class="code-block" data-responses-for="<%= response_uid %>">
501
+ <div class="code-header">
502
+ <span class="response-title">Response</span>
503
+ <div class="code-header-right">
504
+ <button type="button" class="copy-btn" aria-label="Copy to clipboard">
505
+ <svg class="copy-icon" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>
506
+ <svg class="check-icon" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
507
+ </button>
508
+ </div>
509
+ </div>
510
+ <div class="response-tabs">
511
+ <% responses.each_with_index do |resp, i| -%>
512
+ <span class="response-tab <%= status_class(resp['status']) %><%= ' active' if i.zero? %>"
513
+ data-resp-status="<%= h(resp['status']) %>"
514
+ data-resp-target="<%= response_uid %>"
515
+ title="<%= h(resp['description']) %>"><%= h(resp['status']) %></span>
516
+ <% end -%>
517
+ </div>
518
+ <% responses.each_with_index do |resp, i| -%>
519
+ <%
520
+ body = resp["example"].to_s
521
+ body = "{}" if body.strip.empty?
522
+ lines = body.lines.size
523
+ -%>
524
+ <div class="response-body <%= 'active' if i.zero? %>"
525
+ data-resp-body="<%= h(resp['status']) %>"
526
+ data-resp-target="<%= response_uid %>">
527
+ <div class="code-body">
528
+ <div class="code-line-numbers"><pre><%= (1..lines).to_a.join("\n") %></pre></div>
529
+ <div class="code-content"><pre><%= h(body) %></pre></div>
530
+ </div>
531
+ </div>
532
+ <% end -%>
533
+ </div>
534
+ <% end -%>
535
+ </div>
536
+ <% end -%>
537
+ <% end -%>
538
+ <% end -%>
539
+ </aside>
540
+
541
+ </div>
542
+
543
+ <script>
544
+ (function() {
545
+ const sidebarLinks = document.querySelectorAll('.endpoints a[data-endpoint]');
546
+ const pages = document.querySelectorAll('[data-endpoint-page]');
547
+ const codePanes = document.querySelectorAll('[data-code-pane]');
548
+
549
+ function activate(id) {
550
+ if (!id) return;
551
+ sidebarLinks.forEach(a => a.classList.toggle('active', a.dataset.endpoint === id));
552
+ pages.forEach(p => p.classList.toggle('active', p.dataset.endpointPage === id));
553
+ codePanes.forEach(c => c.classList.toggle('active', c.dataset.codePane === id));
554
+ }
555
+
556
+ function pickInitial() {
557
+ const fromHash = location.hash ? location.hash.slice(1) : null;
558
+ if (fromHash && document.getElementById(fromHash)) return fromHash;
559
+ const first = sidebarLinks[0];
560
+ return first ? first.dataset.endpoint : null;
561
+ }
562
+
563
+ sidebarLinks.forEach(a => {
564
+ a.addEventListener('click', (e) => {
565
+ e.preventDefault();
566
+ const id = a.dataset.endpoint;
567
+ history.replaceState(null, '', '#' + id);
568
+ activate(id);
569
+ window.scrollTo({ top: 0, behavior: 'instant' });
570
+ });
571
+ });
572
+
573
+ window.addEventListener('hashchange', () => activate(location.hash.slice(1)));
574
+
575
+ activate(pickInitial());
576
+
577
+ // Copy-to-clipboard buttons (curl + response)
578
+ function getCopyText(btn) {
579
+ const block = btn.closest('.code-block');
580
+ if (!block) return '';
581
+ // Response box: copy whichever tab is currently active.
582
+ const activeResp = block.querySelector('.response-body.active .code-content pre');
583
+ if (activeResp) return activeResp.textContent;
584
+ // Curl box: only one <pre> in code-content.
585
+ const pre = block.querySelector('.code-content pre');
586
+ return pre ? pre.textContent : '';
587
+ }
588
+
589
+ function flashCopied(btn) {
590
+ btn.classList.add('copied');
591
+ setTimeout(() => btn.classList.remove('copied'), 1500);
592
+ }
593
+
594
+ async function copyText(text) {
595
+ if (navigator.clipboard && window.isSecureContext) {
596
+ try { await navigator.clipboard.writeText(text); return true; } catch (_) { /* fall through */ }
597
+ }
598
+ // Legacy fallback for non-secure contexts (HTTP, file://).
599
+ const ta = document.createElement('textarea');
600
+ ta.value = text;
601
+ ta.style.position = 'fixed'; ta.style.top = '0'; ta.style.opacity = '0';
602
+ document.body.appendChild(ta);
603
+ ta.select();
604
+ let ok = false;
605
+ try { ok = document.execCommand('copy'); } catch (_) { ok = false; }
606
+ document.body.removeChild(ta);
607
+ return ok;
608
+ }
609
+
610
+ document.querySelectorAll('.copy-btn').forEach(btn => {
611
+ btn.addEventListener('click', async () => {
612
+ const text = getCopyText(btn);
613
+ if (!text) return;
614
+ const ok = await copyText(text);
615
+ if (ok) flashCopied(btn);
616
+ });
617
+ });
618
+
619
+ // Response status tab switching
620
+ document.querySelectorAll('.response-tab').forEach(tab => {
621
+ tab.addEventListener('click', () => {
622
+ const target = tab.dataset.respTarget;
623
+ const status = tab.dataset.respStatus;
624
+ document.querySelectorAll('.response-tab[data-resp-target="' + target + '"]').forEach(t => {
625
+ t.classList.toggle('active', t === tab);
626
+ });
627
+ document.querySelectorAll('.response-body[data-resp-target="' + target + '"]').forEach(b => {
628
+ b.classList.toggle('active', b.dataset.respBody === status);
629
+ });
630
+ });
631
+ });
632
+
633
+ // Sidebar filters — text search (unchanged) AND tag click filter (new).
634
+ // Both compose: a sidebar item is visible only if it matches BOTH the
635
+ // current search query and the active tag filter.
636
+ const search = document.getElementById('rad-search');
637
+ const filterPill = document.getElementById('rad-active-tag-filter');
638
+ const filterName = filterPill?.querySelector('.tag-name');
639
+ const clearBtn = filterPill?.querySelector('.clear-filter');
640
+ let activeTag = null;
641
+
642
+ function applyFilters() {
643
+ const q = (search?.value || '').toLowerCase().trim();
644
+ document.querySelectorAll('.endpoints li').forEach(li => {
645
+ const label = (li.dataset.endpointLabel || '').toLowerCase();
646
+ let tags = [];
647
+ try { tags = JSON.parse(li.dataset.endpointTags || '[]'); } catch (_) {}
648
+
649
+ const matchesSearch = !q || label.includes(q);
650
+ const matchesTag = !activeTag || tags.includes(activeTag);
651
+ li.style.display = matchesSearch && matchesTag ? '' : 'none';
652
+ });
653
+ document.querySelectorAll('.section').forEach(section => {
654
+ const visible = Array.from(section.querySelectorAll('li')).some(li => li.style.display !== 'none');
655
+ section.style.display = visible ? '' : 'none';
656
+ });
657
+ }
658
+
659
+ function setTagFilter(tag) {
660
+ // Clicking the same tag toggles off; clicking a different one switches.
661
+ activeTag = (activeTag === tag) ? null : tag;
662
+
663
+ // Reflect on every copy of the badge across the document.
664
+ document.querySelectorAll('.endpoint-meta.tag').forEach(btn => {
665
+ const isActive = activeTag !== null && btn.dataset.tagFilter === activeTag;
666
+ btn.classList.toggle('active', isActive);
667
+ btn.setAttribute('aria-pressed', String(isActive));
668
+ });
669
+
670
+ if (filterPill) {
671
+ filterPill.hidden = !activeTag;
672
+ if (activeTag) filterName.textContent = activeTag;
673
+ }
674
+ applyFilters();
675
+ }
676
+
677
+ document.querySelectorAll('.endpoint-meta.tag').forEach(btn => {
678
+ btn.addEventListener('click', () => setTagFilter(btn.dataset.tagFilter));
679
+ });
680
+ clearBtn?.addEventListener('click', () => setTagFilter(activeTag)); // toggle off
681
+
682
+ if (search) {
683
+ search.addEventListener('input', applyFilters);
684
+ document.addEventListener('keydown', (e) => {
685
+ if (e.key === '/' && document.activeElement !== search) {
686
+ e.preventDefault();
687
+ search.focus();
688
+ }
689
+ });
690
+ }
691
+ })();
692
+ </script>
693
+
694
+ </body>
695
+ </html>