838 lines
28 KiB
HTML
838 lines
28 KiB
HTML
<!DOCTYPE html>
|
|
<!-- Docs engine: https://github.com/skyfallsin/swagger-stripey -->
|
|
<html lang="en" data-theme="dark">
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
<link href="https://fonts.googleapis.com/css2?family=Geist:wght@400;500;600&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet">
|
|
<title id="page-title">API Reference</title>
|
|
<script id="docs-config" type="application/json">
|
|
{
|
|
"specUrl": "./openapi.json",
|
|
"logoUrl": "./fox.png",
|
|
"title": "camofox-browser",
|
|
"subtitle": "API Reference",
|
|
"accent": "#9B30FF",
|
|
"accentLight": "#B366FF",
|
|
"methodGet": "#9B30FF"
|
|
}
|
|
</script>
|
|
<style>
|
|
/* ========================================================
|
|
Design tokens — dark (default)
|
|
======================================================== */
|
|
:root, [data-theme="dark"] {
|
|
--accent: #9B30FF;
|
|
--accent-light: #B366FF;
|
|
--accent-subtle: rgba(155,48,255,0.08);
|
|
--bg-primary: #070E1A;
|
|
--bg-secondary: #0B1628;
|
|
--bg-tertiary: #0F1D33;
|
|
--bg-code: #0a1120;
|
|
--text-primary: #E8F0FF;
|
|
--text-secondary: #8BA3C7;
|
|
--text-muted: #5a7394;
|
|
--border: #1a2a42;
|
|
--border-light: #243550;
|
|
--method-get: #9B30FF;
|
|
--method-post: #2563EB;
|
|
--method-delete: #DC2626;
|
|
--method-put: #D97706;
|
|
--method-patch: #0891B2;
|
|
--code-str: #22C55E;
|
|
--status-2xx: #22C55E;
|
|
--status-4xx: #F59E0B;
|
|
--status-5xx: #EF4444;
|
|
--deprecated-bg: rgba(220,38,38,0.15);
|
|
--deprecated-fg: #f87171;
|
|
--toggle-bg: var(--bg-tertiary);
|
|
--toggle-fg: var(--text-secondary);
|
|
}
|
|
|
|
/* ========================================================
|
|
Light theme
|
|
======================================================== */
|
|
[data-theme="light"] {
|
|
--accent: #7C22DB;
|
|
--accent-light: #9333EA;
|
|
--accent-subtle: rgba(124,34,219,0.06);
|
|
--bg-primary: #FFFFFF;
|
|
--bg-secondary: #F8F9FC;
|
|
--bg-tertiary: #F0F2F6;
|
|
--bg-code: #F5F6FA;
|
|
--text-primary: #1A1D26;
|
|
--text-secondary: #5C6370;
|
|
--text-muted: #9CA3AF;
|
|
--border: #E2E5EC;
|
|
--border-light: #ECEEF3;
|
|
--method-get: #7C22DB;
|
|
--code-str: #16A34A;
|
|
--status-2xx: #16A34A;
|
|
--status-4xx: #D97706;
|
|
--status-5xx: #DC2626;
|
|
--deprecated-bg: rgba(220,38,38,0.08);
|
|
--deprecated-fg: #DC2626;
|
|
--toggle-bg: #E2E5EC;
|
|
--toggle-fg: #5C6370;
|
|
}
|
|
|
|
/* ========================================================
|
|
Shared tokens
|
|
======================================================== */
|
|
:root {
|
|
--font-body: 'Geist', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
|
--font-mono: 'JetBrains Mono', 'SF Mono', 'Menlo', 'Consolas', monospace;
|
|
--sidebar-width: 260px;
|
|
--code-panel-width: 42%;
|
|
--header-height: 56px;
|
|
}
|
|
|
|
/* ========================================================
|
|
Reset & base
|
|
======================================================== */
|
|
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
|
html { scroll-behavior: smooth; scroll-padding-top: calc(var(--header-height) + 24px); }
|
|
body {
|
|
font-family: var(--font-body);
|
|
background: var(--bg-primary);
|
|
color: var(--text-primary);
|
|
line-height: 1.6;
|
|
-webkit-font-smoothing: antialiased;
|
|
}
|
|
|
|
/* ========================================================
|
|
Header
|
|
======================================================== */
|
|
.header {
|
|
position: fixed; top: 0; left: 0; right: 0; z-index: 100;
|
|
display: flex; align-items: center; gap: 14px;
|
|
padding: 0 24px; height: var(--header-height);
|
|
background: var(--bg-secondary);
|
|
border-bottom: 1px solid var(--border);
|
|
}
|
|
.header img { height: 32px; width: auto; }
|
|
.header .title {
|
|
font-family: var(--font-mono);
|
|
font-size: 16px; font-weight: 600;
|
|
color: var(--text-primary); letter-spacing: -0.5px;
|
|
}
|
|
.header .subtitle {
|
|
font-size: 12px; color: var(--text-secondary);
|
|
margin-left: 12px; font-weight: 400;
|
|
}
|
|
.header .header-right {
|
|
margin-left: auto; display: flex; align-items: center; gap: 12px;
|
|
}
|
|
.header .version {
|
|
font-family: var(--font-mono);
|
|
font-size: 11px; color: var(--text-muted);
|
|
background: var(--bg-tertiary); padding: 3px 10px;
|
|
border-radius: 4px; border: 1px solid var(--border);
|
|
}
|
|
|
|
/* Theme toggle */
|
|
.theme-toggle {
|
|
background: var(--toggle-bg); border: 1px solid var(--border);
|
|
border-radius: 6px; padding: 5px 10px;
|
|
cursor: pointer; font-size: 14px; line-height: 1;
|
|
color: var(--toggle-fg); transition: all 0.2s;
|
|
display: flex; align-items: center; gap: 4px;
|
|
}
|
|
.theme-toggle:hover { border-color: var(--accent); color: var(--accent); }
|
|
|
|
/* Mobile menu toggle */
|
|
.menu-toggle {
|
|
display: none;
|
|
background: var(--toggle-bg); border: 1px solid var(--border);
|
|
border-radius: 6px; padding: 6px 10px;
|
|
cursor: pointer; font-size: 18px; line-height: 1;
|
|
color: var(--toggle-fg);
|
|
}
|
|
.menu-toggle:hover { border-color: var(--accent); color: var(--accent); }
|
|
|
|
/* ========================================================
|
|
Sidebar
|
|
======================================================== */
|
|
.sidebar {
|
|
position: fixed; top: var(--header-height); left: 0; bottom: 0;
|
|
width: var(--sidebar-width); overflow-y: auto;
|
|
background: var(--bg-secondary);
|
|
border-right: 1px solid var(--border);
|
|
padding: 16px 0;
|
|
transition: transform 0.25s ease;
|
|
z-index: 90;
|
|
}
|
|
.sidebar::-webkit-scrollbar { width: 4px; }
|
|
.sidebar::-webkit-scrollbar-thumb { background: var(--border); border-radius: 2px; }
|
|
|
|
.sidebar .tag-group { margin-bottom: 4px; }
|
|
.sidebar .tag-name {
|
|
display: block; padding: 6px 20px;
|
|
font-size: 11px; font-weight: 600;
|
|
text-transform: uppercase; letter-spacing: 0.8px;
|
|
color: var(--text-muted);
|
|
}
|
|
.sidebar .nav-item {
|
|
display: flex; align-items: center; gap: 8px;
|
|
padding: 5px 20px 5px 24px;
|
|
text-decoration: none; color: var(--text-secondary);
|
|
font-size: 13px; transition: all 0.15s;
|
|
border-left: 2px solid transparent;
|
|
}
|
|
.sidebar .nav-item:hover {
|
|
color: var(--text-primary);
|
|
background: var(--accent-subtle);
|
|
}
|
|
.sidebar .nav-item.active {
|
|
color: var(--text-primary);
|
|
border-left-color: var(--accent);
|
|
background: var(--accent-subtle);
|
|
}
|
|
.sidebar .method-badge {
|
|
font-family: var(--font-mono);
|
|
font-size: 9px; font-weight: 600;
|
|
text-transform: uppercase;
|
|
padding: 1px 5px; border-radius: 3px;
|
|
min-width: 36px; text-align: center;
|
|
color: #fff; flex-shrink: 0;
|
|
}
|
|
.sidebar .method-badge.get { background: var(--method-get); }
|
|
.sidebar .method-badge.post { background: var(--method-post); }
|
|
.sidebar .method-badge.delete { background: var(--method-delete); }
|
|
.sidebar .method-badge.put { background: var(--method-put); }
|
|
.sidebar .method-badge.patch { background: var(--method-patch); }
|
|
|
|
/* Mobile overlay when sidebar is open */
|
|
.sidebar-overlay {
|
|
display: none; position: fixed; inset: 0;
|
|
background: rgba(0,0,0,0.5); z-index: 89;
|
|
}
|
|
|
|
/* ========================================================
|
|
Main content — two-panel (Stripe layout)
|
|
======================================================== */
|
|
.main {
|
|
margin-left: var(--sidebar-width);
|
|
margin-top: var(--header-height);
|
|
}
|
|
|
|
.endpoint {
|
|
display: flex; min-height: 100vh;
|
|
border-bottom: 1px solid var(--border);
|
|
}
|
|
.endpoint:last-child { min-height: auto; }
|
|
|
|
/* Left panel — description */
|
|
.endpoint .desc-panel {
|
|
flex: 1; min-width: 0;
|
|
padding: 40px 48px;
|
|
max-width: calc(100% - var(--code-panel-width));
|
|
}
|
|
|
|
/* Right panel — code examples */
|
|
.endpoint .code-panel {
|
|
width: var(--code-panel-width); flex-shrink: 0;
|
|
background: var(--bg-code);
|
|
border-left: 1px solid var(--border);
|
|
padding: 40px 32px;
|
|
position: sticky; top: var(--header-height);
|
|
max-height: calc(100vh - var(--header-height));
|
|
overflow-y: auto;
|
|
}
|
|
.endpoint .code-panel::-webkit-scrollbar { width: 4px; }
|
|
.endpoint .code-panel::-webkit-scrollbar-thumb { background: var(--border); border-radius: 2px; }
|
|
|
|
/* Endpoint title */
|
|
.endpoint-title {
|
|
display: flex; align-items: center; gap: 12px;
|
|
margin-bottom: 8px; flex-wrap: wrap;
|
|
}
|
|
.endpoint-title .method {
|
|
font-family: var(--font-mono);
|
|
font-size: 12px; font-weight: 600;
|
|
text-transform: uppercase;
|
|
padding: 3px 8px; border-radius: 4px;
|
|
color: #fff;
|
|
}
|
|
.endpoint-title .method.get { background: var(--method-get); }
|
|
.endpoint-title .method.post { background: var(--method-post); }
|
|
.endpoint-title .method.delete { background: var(--method-delete); }
|
|
.endpoint-title .method.put { background: var(--method-put); }
|
|
.endpoint-title .method.patch { background: var(--method-patch); }
|
|
|
|
.endpoint-title .path {
|
|
font-family: var(--font-mono);
|
|
font-size: 15px; font-weight: 500;
|
|
color: var(--text-primary);
|
|
word-break: break-all;
|
|
}
|
|
.endpoint-title .path .param { color: var(--accent-light); }
|
|
|
|
.endpoint .summary {
|
|
font-size: 22px; font-weight: 600;
|
|
color: var(--text-primary);
|
|
margin-bottom: 12px; line-height: 1.3;
|
|
}
|
|
|
|
.endpoint .description {
|
|
color: var(--text-secondary);
|
|
font-size: 14px; line-height: 1.7;
|
|
margin-bottom: 28px;
|
|
}
|
|
|
|
.deprecated-badge {
|
|
display: inline-block;
|
|
font-size: 10px; font-weight: 600;
|
|
text-transform: uppercase;
|
|
padding: 2px 8px; border-radius: 3px;
|
|
background: var(--deprecated-bg);
|
|
color: var(--deprecated-fg); margin-left: 8px;
|
|
}
|
|
|
|
/* Parameters table */
|
|
.params-section { margin-bottom: 28px; }
|
|
.params-section h3 {
|
|
font-size: 13px; font-weight: 600;
|
|
text-transform: uppercase; letter-spacing: 0.5px;
|
|
color: var(--text-muted);
|
|
margin-bottom: 12px;
|
|
padding-bottom: 8px;
|
|
border-bottom: 1px solid var(--border);
|
|
}
|
|
|
|
.param-row {
|
|
display: flex; gap: 16px;
|
|
padding: 10px 0;
|
|
border-bottom: 1px solid var(--border);
|
|
font-size: 13px;
|
|
}
|
|
.param-row:last-child { border-bottom: none; }
|
|
|
|
.param-name {
|
|
font-family: var(--font-mono);
|
|
font-weight: 500; min-width: 140px;
|
|
color: var(--text-primary);
|
|
}
|
|
.param-name .required {
|
|
color: var(--accent-light);
|
|
font-size: 10px; margin-left: 4px;
|
|
}
|
|
.param-name .in-badge {
|
|
display: block; font-size: 10px;
|
|
color: var(--text-muted); font-weight: 400;
|
|
margin-top: 2px;
|
|
}
|
|
|
|
.param-desc { color: var(--text-secondary); flex: 1; }
|
|
.param-type {
|
|
font-family: var(--font-mono);
|
|
font-size: 11px; color: var(--accent-light);
|
|
}
|
|
|
|
/* Response status codes */
|
|
.responses-section { margin-bottom: 28px; }
|
|
.response-row {
|
|
display: flex; align-items: baseline; gap: 12px;
|
|
padding: 8px 0;
|
|
border-bottom: 1px solid var(--border);
|
|
font-size: 13px;
|
|
}
|
|
.response-row:last-child { border-bottom: none; }
|
|
.status-code {
|
|
font-family: var(--font-mono);
|
|
font-weight: 600; min-width: 40px;
|
|
}
|
|
.status-code.s2xx { color: var(--status-2xx); }
|
|
.status-code.s4xx { color: var(--status-4xx); }
|
|
.status-code.s5xx { color: var(--status-5xx); }
|
|
.response-desc { color: var(--text-secondary); }
|
|
|
|
/* Code panel content */
|
|
.code-block {
|
|
position: relative;
|
|
background: var(--bg-tertiary);
|
|
border: 1px solid var(--border);
|
|
border-radius: 6px;
|
|
padding: 16px; margin-bottom: 16px;
|
|
overflow-x: auto;
|
|
}
|
|
.code-block .label {
|
|
font-size: 11px; font-weight: 600;
|
|
text-transform: uppercase; letter-spacing: 0.5px;
|
|
color: var(--text-muted);
|
|
margin-bottom: 10px;
|
|
}
|
|
.code-block .copy-btn {
|
|
position: absolute; top: 10px; right: 10px;
|
|
background: var(--bg-secondary); border: 1px solid var(--border);
|
|
border-radius: 4px; padding: 4px 8px;
|
|
cursor: pointer; font-size: 11px; line-height: 1;
|
|
color: var(--text-muted); transition: all 0.15s;
|
|
opacity: 0;
|
|
}
|
|
.code-block:hover .copy-btn { opacity: 1; }
|
|
.code-block .copy-btn:hover { color: var(--text-primary); border-color: var(--accent); }
|
|
.code-block .copy-btn.copied { color: var(--status-2xx); border-color: var(--status-2xx); opacity: 1; }
|
|
.code-block pre {
|
|
font-family: var(--font-mono);
|
|
font-size: 12px; line-height: 1.6;
|
|
color: var(--text-secondary);
|
|
white-space: pre-wrap; word-break: break-all;
|
|
}
|
|
.code-block pre .str { color: var(--code-str); }
|
|
.code-block pre .key { color: var(--accent-light); }
|
|
.code-block pre .comment { color: var(--text-muted); }
|
|
.code-block pre .method-h { color: var(--accent); font-weight: 600; }
|
|
.code-block pre .url-h { color: var(--text-primary); }
|
|
|
|
/* Body schema tree */
|
|
.schema-tree { font-size: 13px; }
|
|
.schema-prop {
|
|
padding: 4px 0;
|
|
display: flex; gap: 8px; align-items: baseline;
|
|
flex-wrap: wrap;
|
|
}
|
|
.schema-prop .prop-name {
|
|
font-family: var(--font-mono);
|
|
color: var(--text-primary); font-weight: 500;
|
|
}
|
|
.schema-prop .prop-type {
|
|
font-family: var(--font-mono);
|
|
font-size: 11px; color: var(--accent-light);
|
|
}
|
|
.schema-prop .prop-required {
|
|
font-size: 10px; color: var(--accent-light);
|
|
}
|
|
.schema-prop .prop-desc {
|
|
color: var(--text-secondary); font-size: 12px;
|
|
}
|
|
|
|
/* Intro section */
|
|
.intro-section {
|
|
padding: 48px;
|
|
border-bottom: 1px solid var(--border);
|
|
max-width: calc(100% - var(--code-panel-width));
|
|
}
|
|
.intro-section h2 {
|
|
font-size: 28px; font-weight: 600;
|
|
margin-bottom: 16px;
|
|
}
|
|
.intro-section p {
|
|
color: var(--text-secondary);
|
|
font-size: 15px; line-height: 1.7;
|
|
max-width: 640px;
|
|
}
|
|
.intro-section .base-url {
|
|
display: inline-block; margin-top: 20px;
|
|
font-family: var(--font-mono);
|
|
font-size: 14px; padding: 8px 16px;
|
|
background: var(--bg-tertiary);
|
|
border: 1px solid var(--border);
|
|
border-radius: 6px;
|
|
color: var(--accent-light);
|
|
}
|
|
|
|
/* ========================================================
|
|
Responsive — tablet (< 1024px): stack code below
|
|
======================================================== */
|
|
@media (max-width: 1024px) {
|
|
:root { --code-panel-width: 0%; }
|
|
|
|
.endpoint { flex-direction: column; min-height: auto; }
|
|
.endpoint .desc-panel {
|
|
max-width: 100%; padding: 32px 24px;
|
|
}
|
|
.endpoint .code-panel {
|
|
width: 100%; position: static; max-height: none;
|
|
border-left: none; border-top: 1px solid var(--border);
|
|
padding: 24px;
|
|
}
|
|
.intro-section { max-width: 100%; padding: 32px 24px; }
|
|
}
|
|
|
|
/* ========================================================
|
|
Responsive — mobile (< 768px): collapsible sidebar
|
|
======================================================== */
|
|
@media (max-width: 768px) {
|
|
:root { --sidebar-width: 280px; }
|
|
|
|
.menu-toggle { display: block; }
|
|
|
|
.sidebar {
|
|
transform: translateX(-100%);
|
|
}
|
|
.sidebar.open {
|
|
transform: translateX(0);
|
|
box-shadow: 4px 0 20px rgba(0,0,0,0.3);
|
|
}
|
|
.sidebar-overlay.open { display: block; }
|
|
|
|
.main { margin-left: 0; }
|
|
.intro-section { max-width: 100%; }
|
|
|
|
.header .subtitle { display: none; }
|
|
.endpoint .desc-panel { padding: 24px 16px; }
|
|
.endpoint .code-panel { padding: 16px; }
|
|
.endpoint .summary { font-size: 18px; }
|
|
.endpoint-title .path { font-size: 13px; }
|
|
|
|
.param-row { flex-direction: column; gap: 4px; }
|
|
.param-name { min-width: unset; }
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
|
|
<div class="header" id="header"></div>
|
|
<div class="sidebar-overlay" id="sidebar-overlay"></div>
|
|
<nav class="sidebar" id="sidebar"></nav>
|
|
<div class="main" id="main"></div>
|
|
|
|
<script>
|
|
// ============================================================
|
|
// Configuration
|
|
// ============================================================
|
|
const docsConfig = window.docsConfig || {};
|
|
|
|
try {
|
|
const configEl = document.getElementById('docs-config');
|
|
if (configEl) Object.assign(docsConfig, JSON.parse(configEl.textContent));
|
|
} catch(e) {}
|
|
|
|
const conf = {
|
|
specUrl: docsConfig.specUrl || './openapi.json',
|
|
logoUrl: docsConfig.logoUrl || null,
|
|
title: docsConfig.title || null,
|
|
subtitle: docsConfig.subtitle || 'API Reference',
|
|
defaultTheme: docsConfig.defaultTheme || 'dark',
|
|
theme: {
|
|
'--accent': docsConfig.accent || null,
|
|
'--accent-light': docsConfig.accentLight || null,
|
|
'--bg-primary': docsConfig.bgPrimary || null,
|
|
'--bg-secondary': docsConfig.bgSecondary || null,
|
|
'--bg-tertiary': docsConfig.bgTertiary || null,
|
|
'--text-primary': docsConfig.textPrimary || null,
|
|
'--text-secondary': docsConfig.textSecondary || null,
|
|
'--font-body': docsConfig.fontBody || null,
|
|
'--font-mono': docsConfig.fontMono || null,
|
|
'--method-get': docsConfig.methodGet || null,
|
|
'--method-post': docsConfig.methodPost || null,
|
|
'--method-delete': docsConfig.methodDelete || null,
|
|
},
|
|
};
|
|
|
|
// Apply theme overrides (only in dark mode — light mode uses its own)
|
|
for (const [prop, val] of Object.entries(conf.theme)) {
|
|
if (val) document.documentElement.style.setProperty(prop, val);
|
|
}
|
|
|
|
// ============================================================
|
|
// Theme toggle
|
|
// ============================================================
|
|
function getStoredTheme() {
|
|
try { return localStorage.getItem('api-docs-theme'); } catch { return null; }
|
|
}
|
|
function setTheme(theme) {
|
|
document.documentElement.setAttribute('data-theme', theme);
|
|
try { localStorage.setItem('api-docs-theme', theme); } catch {}
|
|
const btn = document.getElementById('theme-btn');
|
|
if (btn) btn.textContent = theme === 'dark' ? '☀️' : '🌙';
|
|
}
|
|
setTheme(getStoredTheme() || conf.defaultTheme);
|
|
|
|
// ============================================================
|
|
// Mobile sidebar
|
|
// ============================================================
|
|
function toggleSidebar() {
|
|
document.getElementById('sidebar').classList.toggle('open');
|
|
document.getElementById('sidebar-overlay').classList.toggle('open');
|
|
}
|
|
function closeSidebar() {
|
|
document.getElementById('sidebar').classList.remove('open');
|
|
document.getElementById('sidebar-overlay').classList.remove('open');
|
|
}
|
|
|
|
// ============================================================
|
|
// Render helpers
|
|
// ============================================================
|
|
function esc(s) { const d = document.createElement('div'); d.textContent = s; return d.innerHTML; }
|
|
|
|
function copyCode(btn) {
|
|
const pre = btn.parentElement.querySelector('pre');
|
|
const text = pre.textContent;
|
|
navigator.clipboard.writeText(text).then(() => {
|
|
btn.textContent = 'Copied!';
|
|
btn.classList.add('copied');
|
|
setTimeout(() => { btn.textContent = 'Copy'; btn.classList.remove('copied'); }, 1500);
|
|
});
|
|
}
|
|
function opId(method, path) { return `${method}-${path.replace(/[^a-zA-Z0-9]/g, '-')}`; }
|
|
function highlightPath(path) { return esc(path).replace(/\{([^}]+)\}/g, '<span class="param">{$1}</span>'); }
|
|
function statusClass(code) {
|
|
const n = parseInt(code);
|
|
if (n >= 200 && n < 300) return 's2xx';
|
|
if (n >= 400 && n < 500) return 's4xx';
|
|
return 's5xx';
|
|
}
|
|
|
|
function renderSchemaProps(schema, depth = 0) {
|
|
if (!schema || !schema.properties) return '';
|
|
const required = new Set(schema.required || []);
|
|
let html = '';
|
|
for (const [name, prop] of Object.entries(schema.properties)) {
|
|
const type = prop.type || (prop.$ref ? prop.$ref.split('/').pop() : 'object');
|
|
html += `<div class="schema-prop" style="padding-left:${depth*16}px">
|
|
<span class="prop-name">${esc(name)}</span>
|
|
<span class="prop-type">${esc(type)}${prop.enum ? ' enum' : ''}</span>
|
|
${required.has(name) ? '<span class="prop-required">required</span>' : ''}
|
|
${prop.description ? `<span class="prop-desc">— ${esc(prop.description)}</span>` : ''}
|
|
</div>`;
|
|
if (prop.properties) html += renderSchemaProps(prop, depth + 1);
|
|
if (prop.items?.properties) html += renderSchemaProps(prop.items, depth + 1);
|
|
}
|
|
return html;
|
|
}
|
|
|
|
function buildCurlExample(method, path, op, servers) {
|
|
const base = servers?.[0]?.url || 'http://localhost:9377';
|
|
const url = base + path.replace(/\{(\w+)\}/g, ':$1');
|
|
let curl = `<span class="comment"># ${esc(op.summary || `${method.toUpperCase()} ${path}`)}</span>\ncurl`;
|
|
if (method !== 'get') curl += ` -X <span class="method-h">${method.toUpperCase()}</span>`;
|
|
curl += ` <span class="url-h">${esc(url)}</span>`;
|
|
|
|
const bodySchema = op.requestBody?.content?.['application/json']?.schema;
|
|
if (bodySchema?.properties) {
|
|
curl += ` \\\n -H <span class="str">"Content-Type: application/json"</span>`;
|
|
const props = {};
|
|
for (const [k, v] of Object.entries(bodySchema.properties)) {
|
|
if (v.type === 'string') props[k] = `<${k}>`;
|
|
else if (v.type === 'boolean') props[k] = true;
|
|
else if (v.type === 'integer' || v.type === 'number') props[k] = 0;
|
|
else if (v.type === 'array') props[k] = [];
|
|
else props[k] = {};
|
|
}
|
|
const body = JSON.stringify(props, null, 2)
|
|
.replace(/"([^"]+)":/g, '<span class="key">"$1"</span>:')
|
|
.replace(/"<([^>]+)>"/g, '<span class="str">"<$1>"</span>');
|
|
curl += ` \\\n -d '${body}'`;
|
|
}
|
|
return curl;
|
|
}
|
|
|
|
function buildResponseExample(op) {
|
|
const resp200 = op.responses?.['200'];
|
|
if (!resp200) return null;
|
|
const schema = resp200.content?.['application/json']?.schema;
|
|
if (!schema?.properties) return null;
|
|
|
|
const obj = {};
|
|
for (const [k, v] of Object.entries(schema.properties)) {
|
|
if (v.type === 'string') obj[k] = v.example || '';
|
|
else if (v.type === 'boolean') obj[k] = true;
|
|
else if (v.type === 'integer' || v.type === 'number') obj[k] = 0;
|
|
else if (v.type === 'array') obj[k] = [];
|
|
else if (v.type === 'object' && v.properties) {
|
|
const inner = {};
|
|
for (const [ik, iv] of Object.entries(v.properties)) {
|
|
if (iv.type === 'string') inner[ik] = iv.example || '';
|
|
else if (iv.type === 'boolean') inner[ik] = true;
|
|
else inner[ik] = null;
|
|
}
|
|
obj[k] = inner;
|
|
} else obj[k] = null;
|
|
}
|
|
|
|
return JSON.stringify(obj, null, 2)
|
|
.replace(/"([^"]+)":/g, '<span class="key">"$1"</span>:')
|
|
.replace(/: "([^"]*)"/g, ': <span class="str">"$1"</span>');
|
|
}
|
|
|
|
// ============================================================
|
|
// Main render
|
|
// ============================================================
|
|
async function render() {
|
|
const res = await fetch(conf.specUrl);
|
|
const spec = await res.json();
|
|
|
|
const title = conf.title || spec.info?.title || 'API';
|
|
document.title = `${title} — ${conf.subtitle}`;
|
|
document.getElementById('page-title').textContent = document.title;
|
|
|
|
// Header
|
|
const currentTheme = document.documentElement.getAttribute('data-theme') || 'dark';
|
|
document.getElementById('header').innerHTML = `
|
|
<button class="menu-toggle" onclick="toggleSidebar()" aria-label="Menu">☰</button>
|
|
${conf.logoUrl ? `<img src="${esc(conf.logoUrl)}" alt="">` : ''}
|
|
<span class="title">${esc(title)}</span>
|
|
<span class="subtitle">${esc(conf.subtitle)}</span>
|
|
<div class="header-right">
|
|
<span class="version">v${esc(spec.info?.version || '?')}</span>
|
|
<button class="theme-toggle" id="theme-btn"
|
|
onclick="setTheme(document.documentElement.getAttribute('data-theme')==='dark'?'light':'dark')"
|
|
aria-label="Toggle theme">${currentTheme === 'dark' ? '☀️' : '🌙'}</button>
|
|
</div>
|
|
`;
|
|
|
|
// Group operations by tag
|
|
const tagMap = new Map();
|
|
const tagOrder = (spec.tags || []).map(t => t.name);
|
|
|
|
for (const [path, methods] of Object.entries(spec.paths || {})) {
|
|
for (const [method, op] of Object.entries(methods)) {
|
|
if (method.startsWith('x-')) continue;
|
|
const tag = op.tags?.[0] || 'Other';
|
|
if (!tagMap.has(tag)) tagMap.set(tag, []);
|
|
tagMap.get(tag).push({ method, path, op });
|
|
}
|
|
}
|
|
|
|
const sortedTags = [...tagMap.keys()].sort((a, b) => {
|
|
const ai = tagOrder.indexOf(a), bi = tagOrder.indexOf(b);
|
|
return (ai === -1 ? 999 : ai) - (bi === -1 ? 999 : bi);
|
|
});
|
|
|
|
// Sidebar
|
|
let sidebarHtml = '';
|
|
for (const tag of sortedTags) {
|
|
sidebarHtml += `<div class="tag-group"><span class="tag-name">${esc(tag)}</span>`;
|
|
for (const { method, path, op } of tagMap.get(tag)) {
|
|
const id = opId(method, path);
|
|
const label = op.summary || path;
|
|
sidebarHtml += `<a class="nav-item" href="#${id}" data-id="${id}" onclick="closeSidebar()">
|
|
<span class="method-badge ${method}">${method}</span>
|
|
<span>${esc(label)}</span>
|
|
</a>`;
|
|
}
|
|
sidebarHtml += '</div>';
|
|
}
|
|
document.getElementById('sidebar').innerHTML = sidebarHtml;
|
|
|
|
// Main content
|
|
let mainHtml = '';
|
|
|
|
if (spec.info?.description) {
|
|
const base = spec.servers?.[0]?.url || '';
|
|
mainHtml += `<div class="intro-section">
|
|
<h2>${esc(conf.subtitle)}</h2>
|
|
<p>${esc(spec.info.description)}</p>
|
|
${base ? `<div class="base-url">${esc(base)}</div>` : ''}
|
|
</div>`;
|
|
}
|
|
|
|
for (const tag of sortedTags) {
|
|
for (const { method, path, op } of tagMap.get(tag)) {
|
|
const id = opId(method, path);
|
|
const allParams = op.parameters || [];
|
|
const pathParams = allParams.filter(p => p.in === 'path');
|
|
const queryParams = allParams.filter(p => p.in === 'query');
|
|
const bodySchema = op.requestBody?.content?.['application/json']?.schema;
|
|
const curl = buildCurlExample(method, path, op, spec.servers);
|
|
const respExample = buildResponseExample(op);
|
|
|
|
mainHtml += `<div class="endpoint" id="${id}">
|
|
<div class="desc-panel">
|
|
<div class="endpoint-title">
|
|
<span class="method ${method}">${method.toUpperCase()}</span>
|
|
<span class="path">${highlightPath(path)}</span>
|
|
${op.deprecated ? '<span class="deprecated-badge">Deprecated</span>' : ''}
|
|
</div>
|
|
<div class="summary">${esc(op.summary || '')}</div>
|
|
${op.description ? `<div class="description">${esc(op.description)}</div>` : ''}`;
|
|
|
|
if (pathParams.length) {
|
|
mainHtml += `<div class="params-section"><h3>Path Parameters</h3>`;
|
|
for (const p of pathParams) {
|
|
mainHtml += `<div class="param-row">
|
|
<div class="param-name">${esc(p.name)}${p.required ? '<span class="required">required</span>' : ''}
|
|
<span class="in-badge">${esc(p.in)}</span></div>
|
|
<div class="param-desc">
|
|
<div class="param-type">${esc(p.schema?.type || 'string')}</div>
|
|
${p.description ? esc(p.description) : ''}</div>
|
|
</div>`;
|
|
}
|
|
mainHtml += '</div>';
|
|
}
|
|
|
|
if (queryParams.length) {
|
|
mainHtml += `<div class="params-section"><h3>Query Parameters</h3>`;
|
|
for (const p of queryParams) {
|
|
mainHtml += `<div class="param-row">
|
|
<div class="param-name">${esc(p.name)}${p.required ? '<span class="required">required</span>' : ''}
|
|
<span class="in-badge">${esc(p.in)}</span></div>
|
|
<div class="param-desc">
|
|
<div class="param-type">${esc(p.schema?.type || 'string')}${p.schema?.enum ? ` — ${p.schema.enum.join(', ')}` : ''}</div>
|
|
${p.description ? esc(p.description) : ''}</div>
|
|
</div>`;
|
|
}
|
|
mainHtml += '</div>';
|
|
}
|
|
|
|
if (bodySchema) {
|
|
mainHtml += `<div class="params-section"><h3>Request Body</h3>
|
|
<div class="schema-tree">${renderSchemaProps(bodySchema)}</div></div>`;
|
|
}
|
|
|
|
if (op.responses) {
|
|
mainHtml += `<div class="responses-section"><h3>Responses</h3>`;
|
|
for (const [code, resp] of Object.entries(op.responses)) {
|
|
if (code.startsWith('x-')) continue;
|
|
const desc = resp.description || resp.$ref || '';
|
|
mainHtml += `<div class="response-row">
|
|
<span class="status-code ${statusClass(code)}">${esc(code)}</span>
|
|
<span class="response-desc">${esc(desc)}</span>
|
|
</div>`;
|
|
}
|
|
mainHtml += '</div>';
|
|
}
|
|
|
|
mainHtml += `</div>`; // close desc-panel
|
|
|
|
mainHtml += `<div class="code-panel">
|
|
<div class="code-block">
|
|
<button class="copy-btn" onclick="copyCode(this)">Copy</button>
|
|
<div class="label">Request</div>
|
|
<pre>${curl}</pre>
|
|
</div>`;
|
|
if (respExample) {
|
|
mainHtml += `<div class="code-block">
|
|
<button class="copy-btn" onclick="copyCode(this)">Copy</button>
|
|
<div class="label">Response</div>
|
|
<pre>${respExample}</pre>
|
|
</div>`;
|
|
}
|
|
mainHtml += `</div></div>`;
|
|
}
|
|
}
|
|
|
|
document.getElementById('main').innerHTML = mainHtml;
|
|
|
|
// Active nav tracking via IntersectionObserver
|
|
const observer = new IntersectionObserver((entries) => {
|
|
for (const entry of entries) {
|
|
if (entry.isIntersecting) {
|
|
document.querySelectorAll('.nav-item').forEach(el => el.classList.remove('active'));
|
|
const link = document.querySelector(`.nav-item[data-id="${entry.target.id}"]`);
|
|
if (link) {
|
|
link.classList.add('active');
|
|
link.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
|
|
}
|
|
}
|
|
}
|
|
}, { rootMargin: '-80px 0px -60% 0px', threshold: 0 });
|
|
|
|
document.querySelectorAll('.endpoint[id]').forEach(el => observer.observe(el));
|
|
|
|
// Close sidebar on overlay click
|
|
document.getElementById('sidebar-overlay').addEventListener('click', closeSidebar);
|
|
}
|
|
|
|
render().catch(err => {
|
|
document.getElementById('main').innerHTML =
|
|
`<div class="intro-section"><h2>Error</h2><p>${esc(err.message)}</p></div>`;
|
|
});
|
|
</script>
|
|
</body>
|
|
</html>
|