web: socle du site v2 — thème sombre/clair persisté, layout (nav, tiroir mobile, recherche ⌘K, pied de page), composants partagés (Code Prism + copie, Tabs, Callout, Table, Badge, LangSelector, Markdown sûr, SearchDialog) et moteur de docs 3 colonnes généré depuis /openapi.json (référence par opération, erreurs, guides MDX, changelog)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
41 changed files +4,355 −58
added
hfmarketdata/web/content/changelog.mdx
+43 −0
@@ -0,0 +1,43 @@ | ||
| 1 | +export const meta = { title: 'Changelog', description: 'Dated release notes for the HF Market Data API and website.' } | |
| 2 | + | |
| 3 | +# Changelog | |
| 4 | + | |
| 5 | +All notable changes to the API and the website. Dates are ISO 8601 (UTC). The API follows the [versioning & deprecation policy](/docs/versioning): everything below is **additive** unless flagged otherwise. | |
| 6 | + | |
| 7 | +## 2.0.0 — 2026-09-04 | |
| 8 | + | |
| 9 | +The v2 platform release. Every `/v1/*` endpoint that existed before keeps its path, parameters and response shape. | |
| 10 | + | |
| 11 | +### Added | |
| 12 | + | |
| 13 | +- **Futures individual contracts** — `GET /v1/futures/roots`, `GET /v1/futures/{root}/contracts`, `GET /v1/futures/contract/{symbol}/bars`, `GET /v1/futures/contract/{symbol}/coverage`, `GET /v1/futures/{root}/chain?as_of=`, `GET /v1/futures/{root}/continuous?roll=&adjust=&depth=`, `GET /v1/futures/{root}/term-structure?as_of=`. Symbols accept `ESZ25` and `ESZ2025`. See the [futures guide](/docs/futures-contracts). | |
| 14 | +- **Fundamentals (SEC EDGAR, point-in-time, since 2010)** — statements, facts, ratios (periodic and daily), filings, coverage, screener, frames, plus `wss://…/v1/stream` for filings and `GET /v1/bulk/fundamentals/{year}.parquet`. See the [fundamentals section](/docs/fundamentals/balance-sheet). | |
| 15 | +- **Accounts & API keys** — sign-up with e-mail verification, `Authorization: Bearer hfmd_live_…` keys (hashed at rest, shown once), rotation and revocation, usage dashboard. See [Authentication](/docs/authentication). | |
| 16 | +- **Rate limiting with three tiers** — keyless (per IP, hourly), free account, high usage on request — with `X-RateLimit-*` headers on every response and a `Retry-After` on 429. See [Rate limits](/docs/rate-limits). | |
| 17 | +- **Uniform error envelope** — `{"error": {"code", "message", "docs", "type?", "details?"}}` on every error; the legacy `detail` field is preserved. All codes are listed on the [Errors](/docs/errors) page. | |
| 18 | +- **Response formats** — `format=parquet` on tabular v2 endpoints (counts half against the rows quota), `X-Row-Count` header, ETag / `If-None-Match` (304 responses are free). | |
| 19 | +- **OpenAPI 3.1** at `/openapi.json` with tags, markdown descriptions, examples and per-operation error codes. This documentation is generated from it. | |
| 20 | +- **Website** — new documentation (3-column reference generated from the spec, guides, instant search with ⌘K), playground with deep links (`/playground?ep=…`), pricing/limits page, live status page, dashboard, light theme, MCP server and skills pack under Integrations. | |
| 21 | + | |
| 22 | +### Changed | |
| 23 | + | |
| 24 | +- Error responses now carry `error.code` and `error.docs` in addition to `detail` (additive). | |
| 25 | +- Successful responses carry `X-RateLimit-*` and `X-Row-Count` headers (additive). | |
| 26 | + | |
| 27 | +### Unchanged (guaranteed) | |
| 28 | + | |
| 29 | +- `GET /v1/bars/{asset}/{ticker}`, `GET /v1/bars/{asset}`, `GET /v1/snapshot/{asset}`, `GET /v1/{asset}/tickers`, `GET /v1/status`, `GET /health` and the five `/v1/options/*` endpoints: same paths, parameters, `{"count", "data"}` shape and status codes. Intraday `datetime` values for these endpoints remain naive US/Eastern strings, as before; v2 endpoints emit UTC ISO 8601 (see [Time zones](/docs/time-zones)). | |
| 30 | + | |
| 31 | +## 1.0.1 — 2026-08-10 | |
| 32 | + | |
| 33 | +### Fixed | |
| 34 | + | |
| 35 | +- Options history: `NaN` / `±inf` Greeks are serialised as `null` instead of failing the request with a 500. | |
| 36 | + | |
| 37 | +### Added | |
| 38 | + | |
| 39 | +- Interactive playground on every endpoint of the (v1) documentation site. | |
| 40 | + | |
| 41 | +## 1.0.0 — 2026-08-10 | |
| 42 | + | |
| 43 | +Initial public release: stocks, ETFs, continuous futures, individual futures contract files, crypto, indices and FX bars from 1-minute to daily (FirstRate Data, 26.5 billion rows, 558,814 files, 350 GB), 66 quarterly options archives (2010 Q1 → 2026 Q2) with full Greeks, `format=csv` bulk extraction up to 2,000,000 rows, multi-ticker bars and point-in-time snapshots. | |
added
hfmarketdata/web/content/openapi.snapshot.json
+1852 −0
@@ -0,0 +1,1852 @@ | ||
| 1 | +{ | |
| 2 | + "openapi": "3.1.0", | |
| 3 | + "info": { | |
| 4 | + "title": "HF Market Data API", | |
| 5 | + "description": "Open high-frequency market data API \u2014 intraday & daily bars for stocks, ETFs, futures, crypto, indices and FX, plus full historical options chains (quotes, implied volatility, Greeks) since 2010. Built by Simon-Pierre Boucher (contact@spboucher.ai).", | |
| 6 | + "contact": { | |
| 7 | + "name": "Simon-Pierre Boucher", | |
| 8 | + "url": "https://www.hfmarketdata.io/", | |
| 9 | + "email": "contact@spboucher.ai" | |
| 10 | + }, | |
| 11 | + "license": { | |
| 12 | + "name": "Data: FirstRate Data & SEC EDGAR \u2014 see terms", | |
| 13 | + "url": "https://www.hfmarketdata.io/docs/terms" | |
| 14 | + }, | |
| 15 | + "version": "2.0.0", | |
| 16 | + "x-logo": { | |
| 17 | + "url": "https://www.hfmarketdata.io/logo.svg" | |
| 18 | + }, | |
| 19 | + "x-tiers": { | |
| 20 | + "keyless": { | |
| 21 | + "window": "1h", | |
| 22 | + "requests": 30, | |
| 23 | + "rows": 100000, | |
| 24 | + "max_rows_per_request": 5000 | |
| 25 | + }, | |
| 26 | + "free": { | |
| 27 | + "window": "1m", | |
| 28 | + "requests": 120, | |
| 29 | + "rows": 1000000, | |
| 30 | + "max_rows_per_request": 50000 | |
| 31 | + }, | |
| 32 | + "high_usage": { | |
| 33 | + "window": "1m", | |
| 34 | + "requests": 600, | |
| 35 | + "rows": 10000000, | |
| 36 | + "max_rows_per_request": 200000, | |
| 37 | + "how": "e-mail contact@spboucher.ai" | |
| 38 | + } | |
| 39 | + } | |
| 40 | + }, | |
| 41 | + "servers": [ | |
| 42 | + { | |
| 43 | + "url": "https://www.hfmarketdata.io", | |
| 44 | + "description": "Production" | |
| 45 | + } | |
| 46 | + ], | |
| 47 | + "paths": { | |
| 48 | + "/health": { | |
| 49 | + "get": { | |
| 50 | + "tags": [ | |
| 51 | + "meta" | |
| 52 | + ], | |
| 53 | + "summary": "Health", | |
| 54 | + "operationId": "health_health_get", | |
| 55 | + "responses": { | |
| 56 | + "200": { | |
| 57 | + "description": "Successful Response", | |
| 58 | + "content": { | |
| 59 | + "application/json": { | |
| 60 | + "schema": {} | |
| 61 | + } | |
| 62 | + }, | |
| 63 | + "headers": { | |
| 64 | + "X-RateLimit-Limit-Requests": { | |
| 65 | + "schema": { | |
| 66 | + "type": "integer" | |
| 67 | + }, | |
| 68 | + "description": "Requests allowed in the current window." | |
| 69 | + }, | |
| 70 | + "X-RateLimit-Remaining-Requests": { | |
| 71 | + "schema": { | |
| 72 | + "type": "integer" | |
| 73 | + }, | |
| 74 | + "description": "Requests left in the current window." | |
| 75 | + }, | |
| 76 | + "X-RateLimit-Limit-Rows": { | |
| 77 | + "schema": { | |
| 78 | + "type": "integer" | |
| 79 | + }, | |
| 80 | + "description": "Data rows allowed in the current window." | |
| 81 | + }, | |
| 82 | + "X-RateLimit-Remaining-Rows": { | |
| 83 | + "schema": { | |
| 84 | + "type": "integer" | |
| 85 | + }, | |
| 86 | + "description": "Data rows left in the current window." | |
| 87 | + }, | |
| 88 | + "X-RateLimit-Reset": { | |
| 89 | + "schema": { | |
| 90 | + "type": "integer" | |
| 91 | + }, | |
| 92 | + "description": "Unix timestamp (seconds) when the window resets." | |
| 93 | + }, | |
| 94 | + "X-Row-Count": { | |
| 95 | + "schema": { | |
| 96 | + "type": "integer" | |
| 97 | + }, | |
| 98 | + "description": "Rows returned by this response." | |
| 99 | + } | |
| 100 | + } | |
| 101 | + }, | |
| 102 | + "500": { | |
| 103 | + "description": "`INTERNAL_ERROR` \u2014 Unexpected server error.", | |
| 104 | + "content": { | |
| 105 | + "application/json": { | |
| 106 | + "schema": { | |
| 107 | + "$ref": "#/components/schemas/Error" | |
| 108 | + } | |
| 109 | + } | |
| 110 | + } | |
| 111 | + }, | |
| 112 | + "429": { | |
| 113 | + "description": "`RATE_LIMIT_EXCEEDED` \u2014 Request or row quota exhausted for the current window.", | |
| 114 | + "content": { | |
| 115 | + "application/json": { | |
| 116 | + "schema": { | |
| 117 | + "$ref": "#/components/schemas/Error" | |
| 118 | + } | |
| 119 | + } | |
| 120 | + } | |
| 121 | + }, | |
| 122 | + "422": { | |
| 123 | + "description": "`VALIDATION_ERROR` \u2014 The request did not match the endpoint schema.", | |
| 124 | + "content": { | |
| 125 | + "application/json": { | |
| 126 | + "schema": { | |
| 127 | + "$ref": "#/components/schemas/Error" | |
| 128 | + } | |
| 129 | + } | |
| 130 | + } | |
| 131 | + } | |
| 132 | + } | |
| 133 | + } | |
| 134 | + }, | |
| 135 | + "/v1/status": { | |
| 136 | + "get": { | |
| 137 | + "tags": [ | |
| 138 | + "meta" | |
| 139 | + ], | |
| 140 | + "summary": "Status", | |
| 141 | + "description": "Dataset inventory: every asset type, timeframe and adjustment with\nthe number of instruments currently available.", | |
| 142 | + "operationId": "status_v1_status_get", | |
| 143 | + "responses": { | |
| 144 | + "200": { | |
| 145 | + "description": "Successful Response", | |
| 146 | + "content": { | |
| 147 | + "application/json": { | |
| 148 | + "schema": {} | |
| 149 | + } | |
| 150 | + }, | |
| 151 | + "headers": { | |
| 152 | + "X-RateLimit-Limit-Requests": { | |
| 153 | + "schema": { | |
| 154 | + "type": "integer" | |
| 155 | + }, | |
| 156 | + "description": "Requests allowed in the current window." | |
| 157 | + }, | |
| 158 | + "X-RateLimit-Remaining-Requests": { | |
| 159 | + "schema": { | |
| 160 | + "type": "integer" | |
| 161 | + }, | |
| 162 | + "description": "Requests left in the current window." | |
| 163 | + }, | |
| 164 | + "X-RateLimit-Limit-Rows": { | |
| 165 | + "schema": { | |
| 166 | + "type": "integer" | |
| 167 | + }, | |
| 168 | + "description": "Data rows allowed in the current window." | |
| 169 | + }, | |
| 170 | + "X-RateLimit-Remaining-Rows": { | |
| 171 | + "schema": { | |
| 172 | + "type": "integer" | |
| 173 | + }, | |
| 174 | + "description": "Data rows left in the current window." | |
| 175 | + }, | |
| 176 | + "X-RateLimit-Reset": { | |
| 177 | + "schema": { | |
| 178 | + "type": "integer" | |
| 179 | + }, | |
| 180 | + "description": "Unix timestamp (seconds) when the window resets." | |
| 181 | + }, | |
| 182 | + "X-Row-Count": { | |
| 183 | + "schema": { | |
| 184 | + "type": "integer" | |
| 185 | + }, | |
| 186 | + "description": "Rows returned by this response." | |
| 187 | + } | |
| 188 | + } | |
| 189 | + }, | |
| 190 | + "500": { | |
| 191 | + "description": "`INTERNAL_ERROR` \u2014 Unexpected server error.", | |
| 192 | + "content": { | |
| 193 | + "application/json": { | |
| 194 | + "schema": { | |
| 195 | + "$ref": "#/components/schemas/Error" | |
| 196 | + } | |
| 197 | + } | |
| 198 | + } | |
| 199 | + }, | |
| 200 | + "429": { | |
| 201 | + "description": "`RATE_LIMIT_EXCEEDED` \u2014 Request or row quota exhausted for the current window.", | |
| 202 | + "content": { | |
| 203 | + "application/json": { | |
| 204 | + "schema": { | |
| 205 | + "$ref": "#/components/schemas/Error" | |
| 206 | + } | |
| 207 | + } | |
| 208 | + } | |
| 209 | + }, | |
| 210 | + "422": { | |
| 211 | + "description": "`VALIDATION_ERROR` \u2014 The request did not match the endpoint schema.", | |
| 212 | + "content": { | |
| 213 | + "application/json": { | |
| 214 | + "schema": { | |
| 215 | + "$ref": "#/components/schemas/Error" | |
| 216 | + } | |
| 217 | + } | |
| 218 | + } | |
| 219 | + } | |
| 220 | + } | |
| 221 | + } | |
| 222 | + }, | |
| 223 | + "/v1/{asset}/tickers": { | |
| 224 | + "get": { | |
| 225 | + "tags": [ | |
| 226 | + "bars" | |
| 227 | + ], | |
| 228 | + "summary": "Tickers", | |
| 229 | + "description": "List available tickers for an asset type / timeframe / adjustment.\nFor `options`, this delegates to the options underlyings listing.", | |
| 230 | + "operationId": "tickers_v1__asset__tickers_get", | |
| 231 | + "parameters": [ | |
| 232 | + { | |
| 233 | + "name": "asset", | |
| 234 | + "in": "path", | |
| 235 | + "required": true, | |
| 236 | + "schema": { | |
| 237 | + "type": "string", | |
| 238 | + "title": "Asset" | |
| 239 | + } | |
| 240 | + }, | |
| 241 | + { | |
| 242 | + "name": "timeframe", | |
| 243 | + "in": "query", | |
| 244 | + "required": false, | |
| 245 | + "schema": { | |
| 246 | + "type": "string", | |
| 247 | + "default": "1day", | |
| 248 | + "title": "Timeframe" | |
| 249 | + } | |
| 250 | + }, | |
| 251 | + { | |
| 252 | + "name": "adjustment", | |
| 253 | + "in": "query", | |
| 254 | + "required": false, | |
| 255 | + "schema": { | |
| 256 | + "anyOf": [ | |
| 257 | + { | |
| 258 | + "type": "string" | |
| 259 | + }, | |
| 260 | + { | |
| 261 | + "type": "null" | |
| 262 | + } | |
| 263 | + ], | |
| 264 | + "title": "Adjustment" | |
| 265 | + } | |
| 266 | + }, | |
| 267 | + { | |
| 268 | + "name": "search", | |
| 269 | + "in": "query", | |
| 270 | + "required": false, | |
| 271 | + "schema": { | |
| 272 | + "anyOf": [ | |
| 273 | + { | |
| 274 | + "type": "string" | |
| 275 | + }, | |
| 276 | + { | |
| 277 | + "type": "null" | |
| 278 | + } | |
| 279 | + ], | |
| 280 | + "description": "Substring filter", | |
| 281 | + "title": "Search" | |
| 282 | + }, | |
| 283 | + "description": "Substring filter" | |
| 284 | + }, | |
| 285 | + { | |
| 286 | + "name": "limit", | |
| 287 | + "in": "query", | |
| 288 | + "required": false, | |
| 289 | + "schema": { | |
| 290 | + "type": "integer", | |
| 291 | + "minimum": 1, | |
| 292 | + "default": 10000, | |
| 293 | + "title": "Limit" | |
| 294 | + } | |
| 295 | + } | |
| 296 | + ], | |
| 297 | + "responses": { | |
| 298 | + "200": { | |
| 299 | + "description": "Successful Response", | |
| 300 | + "content": { | |
| 301 | + "application/json": { | |
| 302 | + "schema": {} | |
| 303 | + } | |
| 304 | + }, | |
| 305 | + "headers": { | |
| 306 | + "X-RateLimit-Limit-Requests": { | |
| 307 | + "schema": { | |
| 308 | + "type": "integer" | |
| 309 | + }, | |
| 310 | + "description": "Requests allowed in the current window." | |
| 311 | + }, | |
| 312 | + "X-RateLimit-Remaining-Requests": { | |
| 313 | + "schema": { | |
| 314 | + "type": "integer" | |
| 315 | + }, | |
| 316 | + "description": "Requests left in the current window." | |
| 317 | + }, | |
| 318 | + "X-RateLimit-Limit-Rows": { | |
| 319 | + "schema": { | |
| 320 | + "type": "integer" | |
| 321 | + }, | |
| 322 | + "description": "Data rows allowed in the current window." | |
| 323 | + }, | |
| 324 | + "X-RateLimit-Remaining-Rows": { | |
| 325 | + "schema": { | |
| 326 | + "type": "integer" | |
| 327 | + }, | |
| 328 | + "description": "Data rows left in the current window." | |
| 329 | + }, | |
| 330 | + "X-RateLimit-Reset": { | |
| 331 | + "schema": { | |
| 332 | + "type": "integer" | |
| 333 | + }, | |
| 334 | + "description": "Unix timestamp (seconds) when the window resets." | |
| 335 | + }, | |
| 336 | + "X-Row-Count": { | |
| 337 | + "schema": { | |
| 338 | + "type": "integer" | |
| 339 | + }, | |
| 340 | + "description": "Rows returned by this response." | |
| 341 | + } | |
| 342 | + } | |
| 343 | + }, | |
| 344 | + "422": { | |
| 345 | + "description": "Validation Error\n\n`VALIDATION_ERROR` \u2014 The request did not match the endpoint schema.", | |
| 346 | + "content": { | |
| 347 | + "application/json": { | |
| 348 | + "schema": { | |
| 349 | + "$ref": "#/components/schemas/Error" | |
| 350 | + } | |
| 351 | + } | |
| 352 | + } | |
| 353 | + }, | |
| 354 | + "500": { | |
| 355 | + "description": "`INTERNAL_ERROR` \u2014 Unexpected server error.", | |
| 356 | + "content": { | |
| 357 | + "application/json": { | |
| 358 | + "schema": { | |
| 359 | + "$ref": "#/components/schemas/Error" | |
| 360 | + } | |
| 361 | + } | |
| 362 | + } | |
| 363 | + }, | |
| 364 | + "429": { | |
| 365 | + "description": "`RATE_LIMIT_EXCEEDED` \u2014 Request or row quota exhausted for the current window.", | |
| 366 | + "content": { | |
| 367 | + "application/json": { | |
| 368 | + "schema": { | |
| 369 | + "$ref": "#/components/schemas/Error" | |
| 370 | + } | |
| 371 | + } | |
| 372 | + } | |
| 373 | + } | |
| 374 | + } | |
| 375 | + } | |
| 376 | + }, | |
| 377 | + "/v1/bars/{asset}/{ticker}": { | |
| 378 | + "get": { | |
| 379 | + "tags": [ | |
| 380 | + "bars" | |
| 381 | + ], | |
| 382 | + "summary": "Bars", | |
| 383 | + "description": "OHLCV bars for one instrument. Daily futures bars also carry open\ninterest. `format=csv` allows bulk extraction (up to 2M rows).", | |
| 384 | + "operationId": "bars_v1_bars__asset___ticker__get", | |
| 385 | + "parameters": [ | |
| 386 | + { | |
| 387 | + "name": "asset", | |
| 388 | + "in": "path", | |
| 389 | + "required": true, | |
| 390 | + "schema": { | |
| 391 | + "type": "string", | |
| 392 | + "title": "Asset" | |
| 393 | + } | |
| 394 | + }, | |
| 395 | + { | |
| 396 | + "name": "ticker", | |
| 397 | + "in": "path", | |
| 398 | + "required": true, | |
| 399 | + "schema": { | |
| 400 | + "type": "string", | |
| 401 | + "title": "Ticker" | |
| 402 | + } | |
| 403 | + }, | |
| 404 | + { | |
| 405 | + "name": "timeframe", | |
| 406 | + "in": "query", | |
| 407 | + "required": false, | |
| 408 | + "schema": { | |
| 409 | + "type": "string", | |
| 410 | + "description": "1min|5min|30min|1hour|1day", | |
| 411 | + "default": "1day", | |
| 412 | + "title": "Timeframe" | |
| 413 | + }, | |
| 414 | + "description": "1min|5min|30min|1hour|1day" | |
| 415 | + }, | |
| 416 | + { | |
| 417 | + "name": "adjustment", | |
| 418 | + "in": "query", | |
| 419 | + "required": false, | |
| 420 | + "schema": { | |
| 421 | + "anyOf": [ | |
| 422 | + { | |
| 423 | + "type": "string" | |
| 424 | + }, | |
| 425 | + { | |
| 426 | + "type": "null" | |
| 427 | + } | |
| 428 | + ], | |
| 429 | + "description": "Default depends on asset type", | |
| 430 | + "title": "Adjustment" | |
| 431 | + }, | |
| 432 | + "description": "Default depends on asset type" | |
| 433 | + }, | |
| 434 | + { | |
| 435 | + "name": "start", | |
| 436 | + "in": "query", | |
| 437 | + "required": false, | |
| 438 | + "schema": { | |
| 439 | + "anyOf": [ | |
| 440 | + { | |
| 441 | + "type": "string" | |
| 442 | + }, | |
| 443 | + { | |
| 444 | + "type": "null" | |
| 445 | + } | |
| 446 | + ], | |
| 447 | + "description": "ISO date/datetime lower bound", | |
| 448 | + "title": "Start" | |
| 449 | + }, | |
| 450 | + "description": "ISO date/datetime lower bound" | |
| 451 | + }, | |
| 452 | + { | |
| 453 | + "name": "end", | |
| 454 | + "in": "query", | |
| 455 | + "required": false, | |
| 456 | + "schema": { | |
| 457 | + "anyOf": [ | |
| 458 | + { | |
| 459 | + "type": "string" | |
| 460 | + }, | |
| 461 | + { | |
| 462 | + "type": "null" | |
| 463 | + } | |
| 464 | + ], | |
| 465 | + "description": "ISO date/datetime upper bound", | |
| 466 | + "title": "End" | |
| 467 | + }, | |
| 468 | + "description": "ISO date/datetime upper bound" | |
| 469 | + }, | |
| 470 | + { | |
| 471 | + "name": "order", | |
| 472 | + "in": "query", | |
| 473 | + "required": false, | |
| 474 | + "schema": { | |
| 475 | + "type": "string", | |
| 476 | + "pattern": "^(asc|desc)$", | |
| 477 | + "default": "asc", | |
| 478 | + "title": "Order" | |
| 479 | + } | |
| 480 | + }, | |
| 481 | + { | |
| 482 | + "name": "limit", | |
| 483 | + "in": "query", | |
| 484 | + "required": false, | |
| 485 | + "schema": { | |
| 486 | + "type": "integer", | |
| 487 | + "minimum": 1, | |
| 488 | + "default": 5000, | |
| 489 | + "title": "Limit" | |
| 490 | + } | |
| 491 | + }, | |
| 492 | + { | |
| 493 | + "name": "format", | |
| 494 | + "in": "query", | |
| 495 | + "required": false, | |
| 496 | + "schema": { | |
| 497 | + "type": "string", | |
| 498 | + "pattern": "^(json|csv)$", | |
| 499 | + "default": "json", | |
| 500 | + "title": "Format" | |
| 501 | + } | |
| 502 | + } | |
| 503 | + ], | |
| 504 | + "responses": { | |
| 505 | + "200": { | |
| 506 | + "description": "Successful Response", | |
| 507 | + "content": { | |
| 508 | + "application/json": { | |
| 509 | + "schema": {} | |
| 510 | + } | |
| 511 | + }, | |
| 512 | + "headers": { | |
| 513 | + "X-RateLimit-Limit-Requests": { | |
| 514 | + "schema": { | |
| 515 | + "type": "integer" | |
| 516 | + }, | |
| 517 | + "description": "Requests allowed in the current window." | |
| 518 | + }, | |
| 519 | + "X-RateLimit-Remaining-Requests": { | |
| 520 | + "schema": { | |
| 521 | + "type": "integer" | |
| 522 | + }, | |
| 523 | + "description": "Requests left in the current window." | |
| 524 | + }, | |
| 525 | + "X-RateLimit-Limit-Rows": { | |
| 526 | + "schema": { | |
| 527 | + "type": "integer" | |
| 528 | + }, | |
| 529 | + "description": "Data rows allowed in the current window." | |
| 530 | + }, | |
| 531 | + "X-RateLimit-Remaining-Rows": { | |
| 532 | + "schema": { | |
| 533 | + "type": "integer" | |
| 534 | + }, | |
| 535 | + "description": "Data rows left in the current window." | |
| 536 | + }, | |
| 537 | + "X-RateLimit-Reset": { | |
| 538 | + "schema": { | |
| 539 | + "type": "integer" | |
| 540 | + }, | |
| 541 | + "description": "Unix timestamp (seconds) when the window resets." | |
| 542 | + }, | |
| 543 | + "X-Row-Count": { | |
| 544 | + "schema": { | |
| 545 | + "type": "integer" | |
| 546 | + }, | |
| 547 | + "description": "Rows returned by this response." | |
| 548 | + } | |
| 549 | + } | |
| 550 | + }, | |
| 551 | + "422": { | |
| 552 | + "description": "Validation Error\n\n`VALIDATION_ERROR` \u2014 The request did not match the endpoint schema.", | |
| 553 | + "content": { | |
| 554 | + "application/json": { | |
| 555 | + "schema": { | |
| 556 | + "$ref": "#/components/schemas/Error" | |
| 557 | + } | |
| 558 | + } | |
| 559 | + } | |
| 560 | + }, | |
| 561 | + "500": { | |
| 562 | + "description": "`INTERNAL_ERROR` \u2014 Unexpected server error.", | |
| 563 | + "content": { | |
| 564 | + "application/json": { | |
| 565 | + "schema": { | |
| 566 | + "$ref": "#/components/schemas/Error" | |
| 567 | + } | |
| 568 | + } | |
| 569 | + } | |
| 570 | + }, | |
| 571 | + "429": { | |
| 572 | + "description": "`RATE_LIMIT_EXCEEDED` \u2014 Request or row quota exhausted for the current window.", | |
| 573 | + "content": { | |
| 574 | + "application/json": { | |
| 575 | + "schema": { | |
| 576 | + "$ref": "#/components/schemas/Error" | |
| 577 | + } | |
| 578 | + } | |
| 579 | + } | |
| 580 | + } | |
| 581 | + } | |
| 582 | + } | |
| 583 | + }, | |
| 584 | + "/v1/bars/{asset}": { | |
| 585 | + "get": { | |
| 586 | + "tags": [ | |
| 587 | + "bars" | |
| 588 | + ], | |
| 589 | + "summary": "Bars Multi", | |
| 590 | + "description": "Bars for SEVERAL instruments in one call. Combine with `start`/`end`\nto slice any precise window \u2014 e.g. 4 hours of 1-minute bars across a\nwhole watchlist. The row limit applies per ticker.", | |
| 591 | + "operationId": "bars_multi_v1_bars__asset__get", | |
| 592 | + "parameters": [ | |
| 593 | + { | |
| 594 | + "name": "asset", | |
| 595 | + "in": "path", | |
| 596 | + "required": true, | |
| 597 | + "schema": { | |
| 598 | + "type": "string", | |
| 599 | + "title": "Asset" | |
| 600 | + } | |
| 601 | + }, | |
| 602 | + { | |
| 603 | + "name": "tickers", | |
| 604 | + "in": "query", | |
| 605 | + "required": true, | |
| 606 | + "schema": { | |
| 607 | + "type": "string", | |
| 608 | + "description": "Comma-separated list, e.g. AAPL,MSFT,TSLA (max 50)", | |
| 609 | + "title": "Tickers" | |
| 610 | + }, | |
| 611 | + "description": "Comma-separated list, e.g. AAPL,MSFT,TSLA (max 50)" | |
| 612 | + }, | |
| 613 | + { | |
| 614 | + "name": "timeframe", | |
| 615 | + "in": "query", | |
| 616 | + "required": false, | |
| 617 | + "schema": { | |
| 618 | + "type": "string", | |
| 619 | + "default": "1day", | |
| 620 | + "title": "Timeframe" | |
| 621 | + } | |
| 622 | + }, | |
| 623 | + { | |
| 624 | + "name": "adjustment", | |
| 625 | + "in": "query", | |
| 626 | + "required": false, | |
| 627 | + "schema": { | |
| 628 | + "anyOf": [ | |
| 629 | + { | |
| 630 | + "type": "string" | |
| 631 | + }, | |
| 632 | + { | |
| 633 | + "type": "null" | |
| 634 | + } | |
| 635 | + ], | |
| 636 | + "title": "Adjustment" | |
| 637 | + } | |
| 638 | + }, | |
| 639 | + { | |
| 640 | + "name": "start", | |
| 641 | + "in": "query", | |
| 642 | + "required": false, | |
| 643 | + "schema": { | |
| 644 | + "anyOf": [ | |
| 645 | + { | |
| 646 | + "type": "string" | |
| 647 | + }, | |
| 648 | + { | |
| 649 | + "type": "null" | |
| 650 | + } | |
| 651 | + ], | |
| 652 | + "description": "ISO date/datetime lower bound", | |
| 653 | + "title": "Start" | |
| 654 | + }, | |
| 655 | + "description": "ISO date/datetime lower bound" | |
| 656 | + }, | |
| 657 | + { | |
| 658 | + "name": "end", | |
| 659 | + "in": "query", | |
| 660 | + "required": false, | |
| 661 | + "schema": { | |
| 662 | + "anyOf": [ | |
| 663 | + { | |
| 664 | + "type": "string" | |
| 665 | + }, | |
| 666 | + { | |
| 667 | + "type": "null" | |
| 668 | + } | |
| 669 | + ], | |
| 670 | + "description": "ISO date/datetime upper bound", | |
| 671 | + "title": "End" | |
| 672 | + }, | |
| 673 | + "description": "ISO date/datetime upper bound" | |
| 674 | + }, | |
| 675 | + { | |
| 676 | + "name": "order", | |
| 677 | + "in": "query", | |
| 678 | + "required": false, | |
| 679 | + "schema": { | |
| 680 | + "type": "string", | |
| 681 | + "pattern": "^(asc|desc)$", | |
| 682 | + "default": "asc", | |
| 683 | + "title": "Order" | |
| 684 | + } | |
| 685 | + }, | |
| 686 | + { | |
| 687 | + "name": "limit", | |
| 688 | + "in": "query", | |
| 689 | + "required": false, | |
| 690 | + "schema": { | |
| 691 | + "type": "integer", | |
| 692 | + "minimum": 1, | |
| 693 | + "description": "Max rows PER TICKER", | |
| 694 | + "default": 5000, | |
| 695 | + "title": "Limit" | |
| 696 | + }, | |
| 697 | + "description": "Max rows PER TICKER" | |
| 698 | + }, | |
| 699 | + { | |
| 700 | + "name": "format", | |
| 701 | + "in": "query", | |
| 702 | + "required": false, | |
| 703 | + "schema": { | |
| 704 | + "type": "string", | |
| 705 | + "pattern": "^(json|csv)$", | |
| 706 | + "default": "json", | |
| 707 | + "title": "Format" | |
| 708 | + } | |
| 709 | + } | |
| 710 | + ], | |
| 711 | + "responses": { | |
| 712 | + "200": { | |
| 713 | + "description": "Successful Response", | |
| 714 | + "content": { | |
| 715 | + "application/json": { | |
| 716 | + "schema": {} | |
| 717 | + } | |
| 718 | + }, | |
| 719 | + "headers": { | |
| 720 | + "X-RateLimit-Limit-Requests": { | |
| 721 | + "schema": { | |
| 722 | + "type": "integer" | |
| 723 | + }, | |
| 724 | + "description": "Requests allowed in the current window." | |
| 725 | + }, | |
| 726 | + "X-RateLimit-Remaining-Requests": { | |
| 727 | + "schema": { | |
| 728 | + "type": "integer" | |
| 729 | + }, | |
| 730 | + "description": "Requests left in the current window." | |
| 731 | + }, | |
| 732 | + "X-RateLimit-Limit-Rows": { | |
| 733 | + "schema": { | |
| 734 | + "type": "integer" | |
| 735 | + }, | |
| 736 | + "description": "Data rows allowed in the current window." | |
| 737 | + }, | |
| 738 | + "X-RateLimit-Remaining-Rows": { | |
| 739 | + "schema": { | |
| 740 | + "type": "integer" | |
| 741 | + }, | |
| 742 | + "description": "Data rows left in the current window." | |
| 743 | + }, | |
| 744 | + "X-RateLimit-Reset": { | |
| 745 | + "schema": { | |
| 746 | + "type": "integer" | |
| 747 | + }, | |
| 748 | + "description": "Unix timestamp (seconds) when the window resets." | |
| 749 | + }, | |
| 750 | + "X-Row-Count": { | |
| 751 | + "schema": { | |
| 752 | + "type": "integer" | |
| 753 | + }, | |
| 754 | + "description": "Rows returned by this response." | |
| 755 | + } | |
| 756 | + } | |
| 757 | + }, | |
| 758 | + "422": { | |
| 759 | + "description": "Validation Error\n\n`VALIDATION_ERROR` \u2014 The request did not match the endpoint schema.", | |
| 760 | + "content": { | |
| 761 | + "application/json": { | |
| 762 | + "schema": { | |
| 763 | + "$ref": "#/components/schemas/Error" | |
| 764 | + } | |
| 765 | + } | |
| 766 | + } | |
| 767 | + }, | |
| 768 | + "500": { | |
| 769 | + "description": "`INTERNAL_ERROR` \u2014 Unexpected server error.", | |
| 770 | + "content": { | |
| 771 | + "application/json": { | |
| 772 | + "schema": { | |
| 773 | + "$ref": "#/components/schemas/Error" | |
| 774 | + } | |
| 775 | + } | |
| 776 | + } | |
| 777 | + }, | |
| 778 | + "429": { | |
| 779 | + "description": "`RATE_LIMIT_EXCEEDED` \u2014 Request or row quota exhausted for the current window.", | |
| 780 | + "content": { | |
| 781 | + "application/json": { | |
| 782 | + "schema": { | |
| 783 | + "$ref": "#/components/schemas/Error" | |
| 784 | + } | |
| 785 | + } | |
| 786 | + } | |
| 787 | + } | |
| 788 | + } | |
| 789 | + } | |
| 790 | + }, | |
| 791 | + "/v1/snapshot/{asset}": { | |
| 792 | + "get": { | |
| 793 | + "tags": [ | |
| 794 | + "bars" | |
| 795 | + ], | |
| 796 | + "summary": "Snapshot", | |
| 797 | + "description": "Cross-sectional snapshot: for each requested instrument, the last bar\nat or before the given moment \u2014 the state of a whole watchlist at one\nprecise point in time.", | |
| 798 | + "operationId": "snapshot_v1_snapshot__asset__get", | |
| 799 | + "parameters": [ | |
| 800 | + { | |
| 801 | + "name": "asset", | |
| 802 | + "in": "path", | |
| 803 | + "required": true, | |
| 804 | + "schema": { | |
| 805 | + "type": "string", | |
| 806 | + "title": "Asset" | |
| 807 | + } | |
| 808 | + }, | |
| 809 | + { | |
| 810 | + "name": "tickers", | |
| 811 | + "in": "query", | |
| 812 | + "required": true, | |
| 813 | + "schema": { | |
| 814 | + "type": "string", | |
| 815 | + "description": "Comma-separated list, e.g. AAPL,MSFT,TSLA (max 50)", | |
| 816 | + "title": "Tickers" | |
| 817 | + }, | |
| 818 | + "description": "Comma-separated list, e.g. AAPL,MSFT,TSLA (max 50)" | |
| 819 | + }, | |
| 820 | + { | |
| 821 | + "name": "at", | |
| 822 | + "in": "query", | |
| 823 | + "required": true, | |
| 824 | + "schema": { | |
| 825 | + "type": "string", | |
| 826 | + "description": "Precise moment, e.g. 2024-06-03 10:35:00", | |
| 827 | + "title": "At" | |
| 828 | + }, | |
| 829 | + "description": "Precise moment, e.g. 2024-06-03 10:35:00" | |
| 830 | + }, | |
| 831 | + { | |
| 832 | + "name": "timeframe", | |
| 833 | + "in": "query", | |
| 834 | + "required": false, | |
| 835 | + "schema": { | |
| 836 | + "type": "string", | |
| 837 | + "default": "1min", | |
| 838 | + "title": "Timeframe" | |
| 839 | + } | |
| 840 | + }, | |
| 841 | + { | |
| 842 | + "name": "adjustment", | |
| 843 | + "in": "query", | |
| 844 | + "required": false, | |
| 845 | + "schema": { | |
| 846 | + "anyOf": [ | |
| 847 | + { | |
| 848 | + "type": "string" | |
| 849 | + }, | |
| 850 | + { | |
| 851 | + "type": "null" | |
| 852 | + } | |
| 853 | + ], | |
| 854 | + "title": "Adjustment" | |
| 855 | + } | |
| 856 | + }, | |
| 857 | + { | |
| 858 | + "name": "format", | |
| 859 | + "in": "query", | |
| 860 | + "required": false, | |
| 861 | + "schema": { | |
| 862 | + "type": "string", | |
| 863 | + "pattern": "^(json|csv)$", | |
| 864 | + "default": "json", | |
| 865 | + "title": "Format" | |
| 866 | + } | |
| 867 | + } | |
| 868 | + ], | |
| 869 | + "responses": { | |
| 870 | + "200": { | |
| 871 | + "description": "Successful Response", | |
| 872 | + "content": { | |
| 873 | + "application/json": { | |
| 874 | + "schema": {} | |
| 875 | + } | |
| 876 | + }, | |
| 877 | + "headers": { | |
| 878 | + "X-RateLimit-Limit-Requests": { | |
| 879 | + "schema": { | |
| 880 | + "type": "integer" | |
| 881 | + }, | |
| 882 | + "description": "Requests allowed in the current window." | |
| 883 | + }, | |
| 884 | + "X-RateLimit-Remaining-Requests": { | |
| 885 | + "schema": { | |
| 886 | + "type": "integer" | |
| 887 | + }, | |
| 888 | + "description": "Requests left in the current window." | |
| 889 | + }, | |
| 890 | + "X-RateLimit-Limit-Rows": { | |
| 891 | + "schema": { | |
| 892 | + "type": "integer" | |
| 893 | + }, | |
| 894 | + "description": "Data rows allowed in the current window." | |
| 895 | + }, | |
| 896 | + "X-RateLimit-Remaining-Rows": { | |
| 897 | + "schema": { | |
| 898 | + "type": "integer" | |
| 899 | + }, | |
| 900 | + "description": "Data rows left in the current window." | |
| 901 | + }, | |
| 902 | + "X-RateLimit-Reset": { | |
| 903 | + "schema": { | |
| 904 | + "type": "integer" | |
| 905 | + }, | |
| 906 | + "description": "Unix timestamp (seconds) when the window resets." | |
| 907 | + }, | |
| 908 | + "X-Row-Count": { | |
| 909 | + "schema": { | |
| 910 | + "type": "integer" | |
| 911 | + }, | |
| 912 | + "description": "Rows returned by this response." | |
| 913 | + } | |
| 914 | + } | |
| 915 | + }, | |
| 916 | + "422": { | |
| 917 | + "description": "Validation Error\n\n`VALIDATION_ERROR` \u2014 The request did not match the endpoint schema.", | |
| 918 | + "content": { | |
| 919 | + "application/json": { | |
| 920 | + "schema": { | |
| 921 | + "$ref": "#/components/schemas/Error" | |
| 922 | + } | |
| 923 | + } | |
| 924 | + } | |
| 925 | + }, | |
| 926 | + "500": { | |
| 927 | + "description": "`INTERNAL_ERROR` \u2014 Unexpected server error.", | |
| 928 | + "content": { | |
| 929 | + "application/json": { | |
| 930 | + "schema": { | |
| 931 | + "$ref": "#/components/schemas/Error" | |
| 932 | + } | |
| 933 | + } | |
| 934 | + } | |
| 935 | + }, | |
| 936 | + "429": { | |
| 937 | + "description": "`RATE_LIMIT_EXCEEDED` \u2014 Request or row quota exhausted for the current window.", | |
| 938 | + "content": { | |
| 939 | + "application/json": { | |
| 940 | + "schema": { | |
| 941 | + "$ref": "#/components/schemas/Error" | |
| 942 | + } | |
| 943 | + } | |
| 944 | + } | |
| 945 | + } | |
| 946 | + } | |
| 947 | + } | |
| 948 | + }, | |
| 949 | + "/v1/options/quarters": { | |
| 950 | + "get": { | |
| 951 | + "tags": [ | |
| 952 | + "options" | |
| 953 | + ], | |
| 954 | + "summary": "Opt Quarters", | |
| 955 | + "description": "Available options archive quarters (e.g. 2010_q1 \u2026 current).", | |
| 956 | + "operationId": "opt_quarters_v1_options_quarters_get", | |
| 957 | + "responses": { | |
| 958 | + "200": { | |
| 959 | + "description": "Successful Response", | |
| 960 | + "content": { | |
| 961 | + "application/json": { | |
| 962 | + "schema": {} | |
| 963 | + } | |
| 964 | + }, | |
| 965 | + "headers": { | |
| 966 | + "X-RateLimit-Limit-Requests": { | |
| 967 | + "schema": { | |
| 968 | + "type": "integer" | |
| 969 | + }, | |
| 970 | + "description": "Requests allowed in the current window." | |
| 971 | + }, | |
| 972 | + "X-RateLimit-Remaining-Requests": { | |
| 973 | + "schema": { | |
| 974 | + "type": "integer" | |
| 975 | + }, | |
| 976 | + "description": "Requests left in the current window." | |
| 977 | + }, | |
| 978 | + "X-RateLimit-Limit-Rows": { | |
| 979 | + "schema": { | |
| 980 | + "type": "integer" | |
| 981 | + }, | |
| 982 | + "description": "Data rows allowed in the current window." | |
| 983 | + }, | |
| 984 | + "X-RateLimit-Remaining-Rows": { | |
| 985 | + "schema": { | |
| 986 | + "type": "integer" | |
| 987 | + }, | |
| 988 | + "description": "Data rows left in the current window." | |
| 989 | + }, | |
| 990 | + "X-RateLimit-Reset": { | |
| 991 | + "schema": { | |
| 992 | + "type": "integer" | |
| 993 | + }, | |
| 994 | + "description": "Unix timestamp (seconds) when the window resets." | |
| 995 | + }, | |
| 996 | + "X-Row-Count": { | |
| 997 | + "schema": { | |
| 998 | + "type": "integer" | |
| 999 | + }, | |
| 1000 | + "description": "Rows returned by this response." | |
| 1001 | + } | |
| 1002 | + } | |
| 1003 | + }, | |
| 1004 | + "500": { | |
| 1005 | + "description": "`INTERNAL_ERROR` \u2014 Unexpected server error.", | |
| 1006 | + "content": { | |
| 1007 | + "application/json": { | |
| 1008 | + "schema": { | |
| 1009 | + "$ref": "#/components/schemas/Error" | |
| 1010 | + } | |
| 1011 | + } | |
| 1012 | + } | |
| 1013 | + }, | |
| 1014 | + "429": { | |
| 1015 | + "description": "`RATE_LIMIT_EXCEEDED` \u2014 Request or row quota exhausted for the current window.", | |
| 1016 | + "content": { | |
| 1017 | + "application/json": { | |
| 1018 | + "schema": { | |
| 1019 | + "$ref": "#/components/schemas/Error" | |
| 1020 | + } | |
| 1021 | + } | |
| 1022 | + } | |
| 1023 | + }, | |
| 1024 | + "422": { | |
| 1025 | + "description": "`VALIDATION_ERROR` \u2014 The request did not match the endpoint schema.", | |
| 1026 | + "content": { | |
| 1027 | + "application/json": { | |
| 1028 | + "schema": { | |
| 1029 | + "$ref": "#/components/schemas/Error" | |
| 1030 | + } | |
| 1031 | + } | |
| 1032 | + } | |
| 1033 | + } | |
| 1034 | + } | |
| 1035 | + } | |
| 1036 | + }, | |
| 1037 | + "/v1/options/tickers": { | |
| 1038 | + "get": { | |
| 1039 | + "tags": [ | |
| 1040 | + "options" | |
| 1041 | + ], | |
| 1042 | + "summary": "Opt Tickers", | |
| 1043 | + "description": "Underlyings available in a given options quarter.", | |
| 1044 | + "operationId": "opt_tickers_v1_options_tickers_get", | |
| 1045 | + "parameters": [ | |
| 1046 | + { | |
| 1047 | + "name": "quarter", | |
| 1048 | + "in": "query", | |
| 1049 | + "required": false, | |
| 1050 | + "schema": { | |
| 1051 | + "anyOf": [ | |
| 1052 | + { | |
| 1053 | + "type": "string" | |
| 1054 | + }, | |
| 1055 | + { | |
| 1056 | + "type": "null" | |
| 1057 | + } | |
| 1058 | + ], | |
| 1059 | + "description": "e.g. 2024_q4; default latest", | |
| 1060 | + "title": "Quarter" | |
| 1061 | + }, | |
| 1062 | + "description": "e.g. 2024_q4; default latest" | |
| 1063 | + }, | |
| 1064 | + { | |
| 1065 | + "name": "search", | |
| 1066 | + "in": "query", | |
| 1067 | + "required": false, | |
| 1068 | + "schema": { | |
| 1069 | + "anyOf": [ | |
| 1070 | + { | |
| 1071 | + "type": "string" | |
| 1072 | + }, | |
| 1073 | + { | |
| 1074 | + "type": "null" | |
| 1075 | + } | |
| 1076 | + ], | |
| 1077 | + "title": "Search" | |
| 1078 | + } | |
| 1079 | + }, | |
| 1080 | + { | |
| 1081 | + "name": "limit", | |
| 1082 | + "in": "query", | |
| 1083 | + "required": false, | |
| 1084 | + "schema": { | |
| 1085 | + "type": "integer", | |
| 1086 | + "minimum": 1, | |
| 1087 | + "default": 10000, | |
| 1088 | + "title": "Limit" | |
| 1089 | + } | |
| 1090 | + } | |
| 1091 | + ], | |
| 1092 | + "responses": { | |
| 1093 | + "200": { | |
| 1094 | + "description": "Successful Response", | |
| 1095 | + "content": { | |
| 1096 | + "application/json": { | |
| 1097 | + "schema": {} | |
| 1098 | + } | |
| 1099 | + }, | |
| 1100 | + "headers": { | |
| 1101 | + "X-RateLimit-Limit-Requests": { | |
| 1102 | + "schema": { | |
| 1103 | + "type": "integer" | |
| 1104 | + }, | |
| 1105 | + "description": "Requests allowed in the current window." | |
| 1106 | + }, | |
| 1107 | + "X-RateLimit-Remaining-Requests": { | |
| 1108 | + "schema": { | |
| 1109 | + "type": "integer" | |
| 1110 | + }, | |
| 1111 | + "description": "Requests left in the current window." | |
| 1112 | + }, | |
| 1113 | + "X-RateLimit-Limit-Rows": { | |
| 1114 | + "schema": { | |
| 1115 | + "type": "integer" | |
| 1116 | + }, | |
| 1117 | + "description": "Data rows allowed in the current window." | |
| 1118 | + }, | |
| 1119 | + "X-RateLimit-Remaining-Rows": { | |
| 1120 | + "schema": { | |
| 1121 | + "type": "integer" | |
| 1122 | + }, | |
| 1123 | + "description": "Data rows left in the current window." | |
| 1124 | + }, | |
| 1125 | + "X-RateLimit-Reset": { | |
| 1126 | + "schema": { | |
| 1127 | + "type": "integer" | |
| 1128 | + }, | |
| 1129 | + "description": "Unix timestamp (seconds) when the window resets." | |
| 1130 | + }, | |
| 1131 | + "X-Row-Count": { | |
| 1132 | + "schema": { | |
| 1133 | + "type": "integer" | |
| 1134 | + }, | |
| 1135 | + "description": "Rows returned by this response." | |
| 1136 | + } | |
| 1137 | + } | |
| 1138 | + }, | |
| 1139 | + "422": { | |
| 1140 | + "description": "Validation Error\n\n`VALIDATION_ERROR` \u2014 The request did not match the endpoint schema.", | |
| 1141 | + "content": { | |
| 1142 | + "application/json": { | |
| 1143 | + "schema": { | |
| 1144 | + "$ref": "#/components/schemas/Error" | |
| 1145 | + } | |
| 1146 | + } | |
| 1147 | + } | |
| 1148 | + }, | |
| 1149 | + "500": { | |
| 1150 | + "description": "`INTERNAL_ERROR` \u2014 Unexpected server error.", | |
| 1151 | + "content": { | |
| 1152 | + "application/json": { | |
| 1153 | + "schema": { | |
| 1154 | + "$ref": "#/components/schemas/Error" | |
| 1155 | + } | |
| 1156 | + } | |
| 1157 | + } | |
| 1158 | + }, | |
| 1159 | + "429": { | |
| 1160 | + "description": "`RATE_LIMIT_EXCEEDED` \u2014 Request or row quota exhausted for the current window.", | |
| 1161 | + "content": { | |
| 1162 | + "application/json": { | |
| 1163 | + "schema": { | |
| 1164 | + "$ref": "#/components/schemas/Error" | |
| 1165 | + } | |
| 1166 | + } | |
| 1167 | + } | |
| 1168 | + } | |
| 1169 | + } | |
| 1170 | + } | |
| 1171 | + }, | |
| 1172 | + "/v1/options/chain/{ticker}": { | |
| 1173 | + "get": { | |
| 1174 | + "tags": [ | |
| 1175 | + "options" | |
| 1176 | + ], | |
| 1177 | + "summary": "Opt Chain", | |
| 1178 | + "description": "Full end-of-day option chain for an underlying: quotes, bid/ask implied\nvolatility, open interest, volume and Greeks (delta, gamma, vega, theta, rho).", | |
| 1179 | + "operationId": "opt_chain_v1_options_chain__ticker__get", | |
| 1180 | + "parameters": [ | |
| 1181 | + { | |
| 1182 | + "name": "ticker", | |
| 1183 | + "in": "path", | |
| 1184 | + "required": true, | |
| 1185 | + "schema": { | |
| 1186 | + "type": "string", | |
| 1187 | + "title": "Ticker" | |
| 1188 | + } | |
| 1189 | + }, | |
| 1190 | + { | |
| 1191 | + "name": "trade_date", | |
| 1192 | + "in": "query", | |
| 1193 | + "required": false, | |
| 1194 | + "schema": { | |
| 1195 | + "anyOf": [ | |
| 1196 | + { | |
| 1197 | + "type": "string" | |
| 1198 | + }, | |
| 1199 | + { | |
| 1200 | + "type": "null" | |
| 1201 | + } | |
| 1202 | + ], | |
| 1203 | + "description": "yyyy-mm-dd; default latest available", | |
| 1204 | + "title": "Trade Date" | |
| 1205 | + }, | |
| 1206 | + "description": "yyyy-mm-dd; default latest available" | |
| 1207 | + }, | |
| 1208 | + { | |
| 1209 | + "name": "expiry", | |
| 1210 | + "in": "query", | |
| 1211 | + "required": false, | |
| 1212 | + "schema": { | |
| 1213 | + "anyOf": [ | |
| 1214 | + { | |
| 1215 | + "type": "string" | |
| 1216 | + }, | |
| 1217 | + { | |
| 1218 | + "type": "null" | |
| 1219 | + } | |
| 1220 | + ], | |
| 1221 | + "description": "Filter on expiry date", | |
| 1222 | + "title": "Expiry" | |
| 1223 | + }, | |
| 1224 | + "description": "Filter on expiry date" | |
| 1225 | + }, | |
| 1226 | + { | |
| 1227 | + "name": "call_put", | |
| 1228 | + "in": "query", | |
| 1229 | + "required": false, | |
| 1230 | + "schema": { | |
| 1231 | + "anyOf": [ | |
| 1232 | + { | |
| 1233 | + "type": "string", | |
| 1234 | + "pattern": "^(c|p)$" | |
| 1235 | + }, | |
| 1236 | + { | |
| 1237 | + "type": "null" | |
| 1238 | + } | |
| 1239 | + ], | |
| 1240 | + "title": "Call Put" | |
| 1241 | + } | |
| 1242 | + }, | |
| 1243 | + { | |
| 1244 | + "name": "strike_min", | |
| 1245 | + "in": "query", | |
| 1246 | + "required": false, | |
| 1247 | + "schema": { | |
| 1248 | + "anyOf": [ | |
| 1249 | + { | |
| 1250 | + "type": "number" | |
| 1251 | + }, | |
| 1252 | + { | |
| 1253 | + "type": "null" | |
| 1254 | + } | |
| 1255 | + ], | |
| 1256 | + "title": "Strike Min" | |
| 1257 | + } | |
| 1258 | + }, | |
| 1259 | + { | |
| 1260 | + "name": "strike_max", | |
| 1261 | + "in": "query", | |
| 1262 | + "required": false, | |
| 1263 | + "schema": { | |
| 1264 | + "anyOf": [ | |
| 1265 | + { | |
| 1266 | + "type": "number" | |
| 1267 | + }, | |
| 1268 | + { | |
| 1269 | + "type": "null" | |
| 1270 | + } | |
| 1271 | + ], | |
| 1272 | + "title": "Strike Max" | |
| 1273 | + } | |
| 1274 | + }, | |
| 1275 | + { | |
| 1276 | + "name": "min_volume", | |
| 1277 | + "in": "query", | |
| 1278 | + "required": false, | |
| 1279 | + "schema": { | |
| 1280 | + "anyOf": [ | |
| 1281 | + { | |
| 1282 | + "type": "number" | |
| 1283 | + }, | |
| 1284 | + { | |
| 1285 | + "type": "null" | |
| 1286 | + } | |
| 1287 | + ], | |
| 1288 | + "title": "Min Volume" | |
| 1289 | + } | |
| 1290 | + }, | |
| 1291 | + { | |
| 1292 | + "name": "limit", | |
| 1293 | + "in": "query", | |
| 1294 | + "required": false, | |
| 1295 | + "schema": { | |
| 1296 | + "type": "integer", | |
| 1297 | + "minimum": 1, | |
| 1298 | + "default": 20000, | |
| 1299 | + "title": "Limit" | |
| 1300 | + } | |
| 1301 | + }, | |
| 1302 | + { | |
| 1303 | + "name": "format", | |
| 1304 | + "in": "query", | |
| 1305 | + "required": false, | |
| 1306 | + "schema": { | |
| 1307 | + "type": "string", | |
| 1308 | + "pattern": "^(json|csv)$", | |
| 1309 | + "default": "json", | |
| 1310 | + "title": "Format" | |
| 1311 | + } | |
| 1312 | + } | |
| 1313 | + ], | |
| 1314 | + "responses": { | |
| 1315 | + "200": { | |
| 1316 | + "description": "Successful Response", | |
| 1317 | + "content": { | |
| 1318 | + "application/json": { | |
| 1319 | + "schema": {} | |
| 1320 | + } | |
| 1321 | + }, | |
| 1322 | + "headers": { | |
| 1323 | + "X-RateLimit-Limit-Requests": { | |
| 1324 | + "schema": { | |
| 1325 | + "type": "integer" | |
| 1326 | + }, | |
| 1327 | + "description": "Requests allowed in the current window." | |
| 1328 | + }, | |
| 1329 | + "X-RateLimit-Remaining-Requests": { | |
| 1330 | + "schema": { | |
| 1331 | + "type": "integer" | |
| 1332 | + }, | |
| 1333 | + "description": "Requests left in the current window." | |
| 1334 | + }, | |
| 1335 | + "X-RateLimit-Limit-Rows": { | |
| 1336 | + "schema": { | |
| 1337 | + "type": "integer" | |
| 1338 | + }, | |
| 1339 | + "description": "Data rows allowed in the current window." | |
| 1340 | + }, | |
| 1341 | + "X-RateLimit-Remaining-Rows": { | |
| 1342 | + "schema": { | |
| 1343 | + "type": "integer" | |
| 1344 | + }, | |
| 1345 | + "description": "Data rows left in the current window." | |
| 1346 | + }, | |
| 1347 | + "X-RateLimit-Reset": { | |
| 1348 | + "schema": { | |
| 1349 | + "type": "integer" | |
| 1350 | + }, | |
| 1351 | + "description": "Unix timestamp (seconds) when the window resets." | |
| 1352 | + }, | |
| 1353 | + "X-Row-Count": { | |
| 1354 | + "schema": { | |
| 1355 | + "type": "integer" | |
| 1356 | + }, | |
| 1357 | + "description": "Rows returned by this response." | |
| 1358 | + } | |
| 1359 | + } | |
| 1360 | + }, | |
| 1361 | + "422": { | |
| 1362 | + "description": "Validation Error\n\n`VALIDATION_ERROR` \u2014 The request did not match the endpoint schema.", | |
| 1363 | + "content": { | |
| 1364 | + "application/json": { | |
| 1365 | + "schema": { | |
| 1366 | + "$ref": "#/components/schemas/Error" | |
| 1367 | + } | |
| 1368 | + } | |
| 1369 | + } | |
| 1370 | + }, | |
| 1371 | + "500": { | |
| 1372 | + "description": "`INTERNAL_ERROR` \u2014 Unexpected server error.", | |
| 1373 | + "content": { | |
| 1374 | + "application/json": { | |
| 1375 | + "schema": { | |
| 1376 | + "$ref": "#/components/schemas/Error" | |
| 1377 | + } | |
| 1378 | + } | |
| 1379 | + } | |
| 1380 | + }, | |
| 1381 | + "429": { | |
| 1382 | + "description": "`RATE_LIMIT_EXCEEDED` \u2014 Request or row quota exhausted for the current window.", | |
| 1383 | + "content": { | |
| 1384 | + "application/json": { | |
| 1385 | + "schema": { | |
| 1386 | + "$ref": "#/components/schemas/Error" | |
| 1387 | + } | |
| 1388 | + } | |
| 1389 | + } | |
| 1390 | + } | |
| 1391 | + } | |
| 1392 | + } | |
| 1393 | + }, | |
| 1394 | + "/v1/options/expirations/{ticker}": { | |
| 1395 | + "get": { | |
| 1396 | + "tags": [ | |
| 1397 | + "options" | |
| 1398 | + ], | |
| 1399 | + "summary": "Opt Expirations", | |
| 1400 | + "description": "Available expiry dates (optionally as of one trade date).", | |
| 1401 | + "operationId": "opt_expirations_v1_options_expirations__ticker__get", | |
| 1402 | + "parameters": [ | |
| 1403 | + { | |
| 1404 | + "name": "ticker", | |
| 1405 | + "in": "path", | |
| 1406 | + "required": true, | |
| 1407 | + "schema": { | |
| 1408 | + "type": "string", | |
| 1409 | + "title": "Ticker" | |
| 1410 | + } | |
| 1411 | + }, | |
| 1412 | + { | |
| 1413 | + "name": "trade_date", | |
| 1414 | + "in": "query", | |
| 1415 | + "required": false, | |
| 1416 | + "schema": { | |
| 1417 | + "anyOf": [ | |
| 1418 | + { | |
| 1419 | + "type": "string" | |
| 1420 | + }, | |
| 1421 | + { | |
| 1422 | + "type": "null" | |
| 1423 | + } | |
| 1424 | + ], | |
| 1425 | + "description": "yyyy-mm-dd", | |
| 1426 | + "title": "Trade Date" | |
| 1427 | + }, | |
| 1428 | + "description": "yyyy-mm-dd" | |
| 1429 | + } | |
| 1430 | + ], | |
| 1431 | + "responses": { | |
| 1432 | + "200": { | |
| 1433 | + "description": "Successful Response", | |
| 1434 | + "content": { | |
| 1435 | + "application/json": { | |
| 1436 | + "schema": {} | |
| 1437 | + } | |
| 1438 | + }, | |
| 1439 | + "headers": { | |
| 1440 | + "X-RateLimit-Limit-Requests": { | |
| 1441 | + "schema": { | |
| 1442 | + "type": "integer" | |
| 1443 | + }, | |
| 1444 | + "description": "Requests allowed in the current window." | |
| 1445 | + }, | |
| 1446 | + "X-RateLimit-Remaining-Requests": { | |
| 1447 | + "schema": { | |
| 1448 | + "type": "integer" | |
| 1449 | + }, | |
| 1450 | + "description": "Requests left in the current window." | |
| 1451 | + }, | |
| 1452 | + "X-RateLimit-Limit-Rows": { | |
| 1453 | + "schema": { | |
| 1454 | + "type": "integer" | |
| 1455 | + }, | |
| 1456 | + "description": "Data rows allowed in the current window." | |
| 1457 | + }, | |
| 1458 | + "X-RateLimit-Remaining-Rows": { | |
| 1459 | + "schema": { | |
| 1460 | + "type": "integer" | |
| 1461 | + }, | |
| 1462 | + "description": "Data rows left in the current window." | |
| 1463 | + }, | |
| 1464 | + "X-RateLimit-Reset": { | |
| 1465 | + "schema": { | |
| 1466 | + "type": "integer" | |
| 1467 | + }, | |
| 1468 | + "description": "Unix timestamp (seconds) when the window resets." | |
| 1469 | + }, | |
| 1470 | + "X-Row-Count": { | |
| 1471 | + "schema": { | |
| 1472 | + "type": "integer" | |
| 1473 | + }, | |
| 1474 | + "description": "Rows returned by this response." | |
| 1475 | + } | |
| 1476 | + } | |
| 1477 | + }, | |
| 1478 | + "422": { | |
| 1479 | + "description": "Validation Error\n\n`VALIDATION_ERROR` \u2014 The request did not match the endpoint schema.", | |
| 1480 | + "content": { | |
| 1481 | + "application/json": { | |
| 1482 | + "schema": { | |
| 1483 | + "$ref": "#/components/schemas/Error" | |
| 1484 | + } | |
| 1485 | + } | |
| 1486 | + } | |
| 1487 | + }, | |
| 1488 | + "500": { | |
| 1489 | + "description": "`INTERNAL_ERROR` \u2014 Unexpected server error.", | |
| 1490 | + "content": { | |
| 1491 | + "application/json": { | |
| 1492 | + "schema": { | |
| 1493 | + "$ref": "#/components/schemas/Error" | |
| 1494 | + } | |
| 1495 | + } | |
| 1496 | + } | |
| 1497 | + }, | |
| 1498 | + "429": { | |
| 1499 | + "description": "`RATE_LIMIT_EXCEEDED` \u2014 Request or row quota exhausted for the current window.", | |
| 1500 | + "content": { | |
| 1501 | + "application/json": { | |
| 1502 | + "schema": { | |
| 1503 | + "$ref": "#/components/schemas/Error" | |
| 1504 | + } | |
| 1505 | + } | |
| 1506 | + } | |
| 1507 | + } | |
| 1508 | + } | |
| 1509 | + } | |
| 1510 | + }, | |
| 1511 | + "/v1/options/history/{ticker}": { | |
| 1512 | + "get": { | |
| 1513 | + "tags": [ | |
| 1514 | + "options" | |
| 1515 | + ], | |
| 1516 | + "summary": "Opt History", | |
| 1517 | + "description": "Daily time series for one specific contract across its whole life.", | |
| 1518 | + "operationId": "opt_history_v1_options_history__ticker__get", | |
| 1519 | + "parameters": [ | |
| 1520 | + { | |
| 1521 | + "name": "ticker", | |
| 1522 | + "in": "path", | |
| 1523 | + "required": true, | |
| 1524 | + "schema": { | |
| 1525 | + "type": "string", | |
| 1526 | + "title": "Ticker" | |
| 1527 | + } | |
| 1528 | + }, | |
| 1529 | + { | |
| 1530 | + "name": "strike", | |
| 1531 | + "in": "query", | |
| 1532 | + "required": true, | |
| 1533 | + "schema": { | |
| 1534 | + "type": "number", | |
| 1535 | + "title": "Strike" | |
| 1536 | + } | |
| 1537 | + }, | |
| 1538 | + { | |
| 1539 | + "name": "expiry", | |
| 1540 | + "in": "query", | |
| 1541 | + "required": true, | |
| 1542 | + "schema": { | |
| 1543 | + "type": "string", | |
| 1544 | + "description": "yyyy-mm-dd", | |
| 1545 | + "title": "Expiry" | |
| 1546 | + }, | |
| 1547 | + "description": "yyyy-mm-dd" | |
| 1548 | + }, | |
| 1549 | + { | |
| 1550 | + "name": "call_put", | |
| 1551 | + "in": "query", | |
| 1552 | + "required": true, | |
| 1553 | + "schema": { | |
| 1554 | + "type": "string", | |
| 1555 | + "pattern": "^(c|p)$", | |
| 1556 | + "title": "Call Put" | |
| 1557 | + } | |
| 1558 | + }, | |
| 1559 | + { | |
| 1560 | + "name": "limit", | |
| 1561 | + "in": "query", | |
| 1562 | + "required": false, | |
| 1563 | + "schema": { | |
| 1564 | + "type": "integer", | |
| 1565 | + "minimum": 1, | |
| 1566 | + "default": 5000, | |
| 1567 | + "title": "Limit" | |
| 1568 | + } | |
| 1569 | + }, | |
| 1570 | + { | |
| 1571 | + "name": "format", | |
| 1572 | + "in": "query", | |
| 1573 | + "required": false, | |
| 1574 | + "schema": { | |
| 1575 | + "type": "string", | |
| 1576 | + "pattern": "^(json|csv)$", | |
| 1577 | + "default": "json", | |
| 1578 | + "title": "Format" | |
| 1579 | + } | |
| 1580 | + } | |
| 1581 | + ], | |
| 1582 | + "responses": { | |
| 1583 | + "200": { | |
| 1584 | + "description": "Successful Response", | |
| 1585 | + "content": { | |
| 1586 | + "application/json": { | |
| 1587 | + "schema": {} | |
| 1588 | + } | |
| 1589 | + }, | |
| 1590 | + "headers": { | |
| 1591 | + "X-RateLimit-Limit-Requests": { | |
| 1592 | + "schema": { | |
| 1593 | + "type": "integer" | |
| 1594 | + }, | |
| 1595 | + "description": "Requests allowed in the current window." | |
| 1596 | + }, | |
| 1597 | + "X-RateLimit-Remaining-Requests": { | |
| 1598 | + "schema": { | |
| 1599 | + "type": "integer" | |
| 1600 | + }, | |
| 1601 | + "description": "Requests left in the current window." | |
| 1602 | + }, | |
| 1603 | + "X-RateLimit-Limit-Rows": { | |
| 1604 | + "schema": { | |
| 1605 | + "type": "integer" | |
| 1606 | + }, | |
| 1607 | + "description": "Data rows allowed in the current window." | |
| 1608 | + }, | |
| 1609 | + "X-RateLimit-Remaining-Rows": { | |
| 1610 | + "schema": { | |
| 1611 | + "type": "integer" | |
| 1612 | + }, | |
| 1613 | + "description": "Data rows left in the current window." | |
| 1614 | + }, | |
| 1615 | + "X-RateLimit-Reset": { | |
| 1616 | + "schema": { | |
| 1617 | + "type": "integer" | |
| 1618 | + }, | |
| 1619 | + "description": "Unix timestamp (seconds) when the window resets." | |
| 1620 | + }, | |
| 1621 | + "X-Row-Count": { | |
| 1622 | + "schema": { | |
| 1623 | + "type": "integer" | |
| 1624 | + }, | |
| 1625 | + "description": "Rows returned by this response." | |
| 1626 | + } | |
| 1627 | + } | |
| 1628 | + }, | |
| 1629 | + "422": { | |
| 1630 | + "description": "Validation Error\n\n`VALIDATION_ERROR` \u2014 The request did not match the endpoint schema.", | |
| 1631 | + "content": { | |
| 1632 | + "application/json": { | |
| 1633 | + "schema": { | |
| 1634 | + "$ref": "#/components/schemas/Error" | |
| 1635 | + } | |
| 1636 | + } | |
| 1637 | + } | |
| 1638 | + }, | |
| 1639 | + "500": { | |
| 1640 | + "description": "`INTERNAL_ERROR` \u2014 Unexpected server error.", | |
| 1641 | + "content": { | |
| 1642 | + "application/json": { | |
| 1643 | + "schema": { | |
| 1644 | + "$ref": "#/components/schemas/Error" | |
| 1645 | + } | |
| 1646 | + } | |
| 1647 | + } | |
| 1648 | + }, | |
| 1649 | + "429": { | |
| 1650 | + "description": "`RATE_LIMIT_EXCEEDED` \u2014 Request or row quota exhausted for the current window.", | |
| 1651 | + "content": { | |
| 1652 | + "application/json": { | |
| 1653 | + "schema": { | |
| 1654 | + "$ref": "#/components/schemas/Error" | |
| 1655 | + } | |
| 1656 | + } | |
| 1657 | + } | |
| 1658 | + } | |
| 1659 | + } | |
| 1660 | + } | |
| 1661 | + } | |
| 1662 | + }, | |
| 1663 | + "components": { | |
| 1664 | + "schemas": { | |
| 1665 | + "HTTPValidationError": { | |
| 1666 | + "properties": { | |
| 1667 | + "detail": { | |
| 1668 | + "items": { | |
| 1669 | + "$ref": "#/components/schemas/ValidationError" | |
| 1670 | + }, | |
| 1671 | + "type": "array", | |
| 1672 | + "title": "Detail" | |
| 1673 | + } | |
| 1674 | + }, | |
| 1675 | + "type": "object", | |
| 1676 | + "title": "HTTPValidationError" | |
| 1677 | + }, | |
| 1678 | + "ValidationError": { | |
| 1679 | + "properties": { | |
| 1680 | + "loc": { | |
| 1681 | + "items": { | |
| 1682 | + "anyOf": [ | |
| 1683 | + { | |
| 1684 | + "type": "string" | |
| 1685 | + }, | |
| 1686 | + { | |
| 1687 | + "type": "integer" | |
| 1688 | + } | |
| 1689 | + ] | |
| 1690 | + }, | |
| 1691 | + "type": "array", | |
| 1692 | + "title": "Location" | |
| 1693 | + }, | |
| 1694 | + "msg": { | |
| 1695 | + "type": "string", | |
| 1696 | + "title": "Message" | |
| 1697 | + }, | |
| 1698 | + "type": { | |
| 1699 | + "type": "string", | |
| 1700 | + "title": "Error Type" | |
| 1701 | + }, | |
| 1702 | + "input": { | |
| 1703 | + "title": "Input" | |
| 1704 | + }, | |
| 1705 | + "ctx": { | |
| 1706 | + "type": "object", | |
| 1707 | + "title": "Context" | |
| 1708 | + } | |
| 1709 | + }, | |
| 1710 | + "type": "object", | |
| 1711 | + "required": [ | |
| 1712 | + "loc", | |
| 1713 | + "msg", | |
| 1714 | + "type" | |
| 1715 | + ], | |
| 1716 | + "title": "ValidationError" | |
| 1717 | + }, | |
| 1718 | + "Error": { | |
| 1719 | + "type": "object", | |
| 1720 | + "required": [ | |
| 1721 | + "error", | |
| 1722 | + "detail" | |
| 1723 | + ], | |
| 1724 | + "properties": { | |
| 1725 | + "error": { | |
| 1726 | + "type": "object", | |
| 1727 | + "required": [ | |
| 1728 | + "code", | |
| 1729 | + "message", | |
| 1730 | + "docs" | |
| 1731 | + ], | |
| 1732 | + "properties": { | |
| 1733 | + "code": { | |
| 1734 | + "type": "string", | |
| 1735 | + "enum": [ | |
| 1736 | + "ASSET_NOT_FOUND", | |
| 1737 | + "AUTH_REQUIRED", | |
| 1738 | + "CONFLICT", | |
| 1739 | + "CONTRACT_NOT_FOUND", | |
| 1740 | + "FORBIDDEN", | |
| 1741 | + "INTERNAL_ERROR", | |
| 1742 | + "INVALID_API_KEY", | |
| 1743 | + "INVALID_CONTRACT_SYMBOL", | |
| 1744 | + "INVALID_PARAMETER", | |
| 1745 | + "NOT_FOUND", | |
| 1746 | + "OPTIONS_UNAVAILABLE", | |
| 1747 | + "RATE_LIMIT_EXCEEDED", | |
| 1748 | + "ROOT_NOT_FOUND", | |
| 1749 | + "ROW_LIMIT_EXCEEDED", | |
| 1750 | + "SERVICE_UNAVAILABLE", | |
| 1751 | + "TICKER_NOT_FOUND", | |
| 1752 | + "VALIDATION_ERROR" | |
| 1753 | + ] | |
| 1754 | + }, | |
| 1755 | + "message": { | |
| 1756 | + "type": "string" | |
| 1757 | + }, | |
| 1758 | + "docs": { | |
| 1759 | + "type": "string", | |
| 1760 | + "format": "uri" | |
| 1761 | + }, | |
| 1762 | + "type": { | |
| 1763 | + "type": "string", | |
| 1764 | + "description": "Sub-type, e.g. requests_per_hour / rows_per_minute for 429." | |
| 1765 | + }, | |
| 1766 | + "details": { | |
| 1767 | + "type": "object", | |
| 1768 | + "additionalProperties": true | |
| 1769 | + } | |
| 1770 | + } | |
| 1771 | + }, | |
| 1772 | + "detail": { | |
| 1773 | + "type": "string", | |
| 1774 | + "description": "Legacy field (same as error.message)." | |
| 1775 | + } | |
| 1776 | + }, | |
| 1777 | + "example": { | |
| 1778 | + "error": { | |
| 1779 | + "code": "CONTRACT_NOT_FOUND", | |
| 1780 | + "message": "No data for contract ESZ19", | |
| 1781 | + "docs": "https://www.hfmarketdata.io/docs/errors#contract_not_found" | |
| 1782 | + }, | |
| 1783 | + "detail": "No data for contract ESZ19" | |
| 1784 | + } | |
| 1785 | + } | |
| 1786 | + }, | |
| 1787 | + "securitySchemes": { | |
| 1788 | + "ApiKey": { | |
| 1789 | + "type": "http", | |
| 1790 | + "scheme": "bearer", | |
| 1791 | + "bearerFormat": "hfmd_live_\u2026", | |
| 1792 | + "description": "Optional. `Authorization: Bearer <key>` or `?api_key=<key>`. Without a key you get the keyless (per-IP, hourly) limits; a free account raises them a lot. High usage: contact@spboucher.ai." | |
| 1793 | + }, | |
| 1794 | + "ApiKeyQuery": { | |
| 1795 | + "type": "apiKey", | |
| 1796 | + "in": "query", | |
| 1797 | + "name": "api_key" | |
| 1798 | + } | |
| 1799 | + } | |
| 1800 | + }, | |
| 1801 | + "tags": [ | |
| 1802 | + { | |
| 1803 | + "name": "meta", | |
| 1804 | + "description": "Health, live dataset inventory and your current limits." | |
| 1805 | + }, | |
| 1806 | + { | |
| 1807 | + "name": "bars", | |
| 1808 | + "description": "Intraday & daily OHLCV bars for stocks, ETFs, crypto, indices, FX and continuous futures." | |
| 1809 | + }, | |
| 1810 | + { | |
| 1811 | + "name": "futures", | |
| 1812 | + "description": "Individual futures contracts, expiry chains, continuous series, term structure." | |
| 1813 | + }, | |
| 1814 | + { | |
| 1815 | + "name": "options", | |
| 1816 | + "description": "End-of-day options chains with quotes, implied volatility and Greeks (since 2010)." | |
| 1817 | + }, | |
| 1818 | + { | |
| 1819 | + "name": "fundamentals", | |
| 1820 | + "description": "SEC EDGAR financial statements, ratios, screener \u2014 point-in-time, since 2010." | |
| 1821 | + }, | |
| 1822 | + { | |
| 1823 | + "name": "stream", | |
| 1824 | + "description": "WebSocket streams (filings)." | |
| 1825 | + }, | |
| 1826 | + { | |
| 1827 | + "name": "bulk", | |
| 1828 | + "description": "Whole-universe Parquet extracts, outside the rows quota." | |
| 1829 | + }, | |
| 1830 | + { | |
| 1831 | + "name": "auth", | |
| 1832 | + "description": "Sign-up, e-mail verification, login, password reset." | |
| 1833 | + }, | |
| 1834 | + { | |
| 1835 | + "name": "me", | |
| 1836 | + "description": "Your account: API keys, usage, tier." | |
| 1837 | + }, | |
| 1838 | + { | |
| 1839 | + "name": "admin", | |
| 1840 | + "description": "Administration (admin role)." | |
| 1841 | + } | |
| 1842 | + ], | |
| 1843 | + "security": [ | |
| 1844 | + {}, | |
| 1845 | + { | |
| 1846 | + "ApiKey": [] | |
| 1847 | + }, | |
| 1848 | + { | |
| 1849 | + "ApiKeyQuery": [] | |
| 1850 | + } | |
| 1851 | + ] | |
| 1852 | +} | |
| \ No newline at end of file | ||
modified
hfmarketdata/web/index.html
+21 −5
@@ -1,13 +1,29 @@ | ||
| 1 | 1 | <!doctype html> |
| 2 | −<html lang="en"> | |
| 2 | +<html lang="en" data-theme="dark"> | |
| 3 | 3 | <head> |
| 4 | 4 | <meta charset="UTF-8" /> |
| 5 | 5 | <meta name="viewport" content="width=device-width, initial-scale=1.0" /> |
| 6 | − <meta name="description" content="HF Market Data — free open API for high-frequency historical market data: stocks, ETFs, futures, crypto, indices, FX (1-minute to daily) and full options chains with Greeks since 2010." /> | |
| 6 | + <meta name="color-scheme" content="dark light" /> | |
| 7 | + <meta name="theme-color" content="#0a0c10" media="(prefers-color-scheme: dark)" /> | |
| 8 | + <meta name="theme-color" content="#ffffff" media="(prefers-color-scheme: light)" /> | |
| 9 | + <meta name="description" content="HF Market Data — free open API for high-frequency historical market data: stocks, ETFs, futures (continuous and individual contracts), crypto, indices, FX from 1-minute to daily, full options chains with Greeks, and SEC EDGAR fundamentals — since 2010." /> | |
| 10 | + <meta property="og:type" content="website" /> | |
| 11 | + <meta property="og:site_name" content="HF Market Data" /> | |
| 12 | + <meta property="og:title" content="HF Market Data — Open High-Frequency Market Data API" /> | |
| 13 | + <meta property="og:description" content="1-minute to daily bars, futures contracts, options chains with Greeks and point-in-time fundamentals. No signup needed to start." /> | |
| 14 | + <meta property="og:image" content="https://www.hfmarketdata.io/og.svg" /> | |
| 15 | + <meta name="twitter:card" content="summary_large_image" /> | |
| 16 | + <link rel="icon" href="/favicon.svg" type="image/svg+xml" /> | |
| 17 | + <link rel="canonical" href="https://www.hfmarketdata.io/" /> | |
| 7 | 18 | <title>HF Market Data — Open High-Frequency Market Data API</title> |
| 8 | − <link rel="preconnect" href="https://fonts.googleapis.com" /> | |
| 9 | − <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin /> | |
| 10 | − <link href="https://fonts.googleapis.com/css2?family=Fraunces:ital,opsz,wght@0,9..144,400;0,9..144,500;0,9..144,600;1,9..144,400;1,9..144,500&family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet" /> | |
| 19 | + <script> | |
| 20 | + // Theme is resolved before first paint (no flash): localStorage → system preference → dark. | |
| 21 | + try { | |
| 22 | + var t = localStorage.getItem('hfmd.theme'); | |
| 23 | + if (t !== 'light' && t !== 'dark') t = window.matchMedia && window.matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark'; | |
| 24 | + document.documentElement.setAttribute('data-theme', t); | |
| 25 | + } catch (e) {} | |
| 26 | + </script> | |
| 11 | 27 | </head> |
| 12 | 28 | <body> |
| 13 | 29 | <div id="root"></div> |
modified
hfmarketdata/web/package-lock.json
+35 −1
@@ -8,6 +8,7 @@ | ||
| 8 | 8 | "name": "hfmarketdata-web", |
| 9 | 9 | "version": "2.0.0", |
| 10 | 10 | "dependencies": { |
| 11 | + "@mdx-js/react": "^3.1.1", | |
| 11 | 12 | "lightweight-charts": "^4.2.0", |
| 12 | 13 | "prism-react-renderer": "^2.4.0", |
| 13 | 14 | "react": "^18.3.1", |
@@ -833,6 +834,23 @@ | ||
| 833 | 834 | "url": "https://opencollective.com/unified" |
| 834 | 835 | } |
| 835 | 836 | }, |
| 837 | + "node_modules/@mdx-js/react": { | |
| 838 | + "version": "3.1.1", | |
| 839 | + "resolved": "https://registry.npmjs.org/@mdx-js/react/-/react-3.1.1.tgz", | |
| 840 | + "integrity": "sha512-f++rKLQgUVYDAtECQ6fn/is15GkEH9+nZPM3MS0RcxVqoTfawHvDlSCH7JbMhAM6uJ32v3eXLvLmLvjGu7PTQw==", | |
| 841 | + "license": "MIT", | |
| 842 | + "dependencies": { | |
| 843 | + "@types/mdx": "^2.0.0" | |
| 844 | + }, | |
| 845 | + "funding": { | |
| 846 | + "type": "opencollective", | |
| 847 | + "url": "https://opencollective.com/unified" | |
| 848 | + }, | |
| 849 | + "peerDependencies": { | |
| 850 | + "@types/react": ">=16", | |
| 851 | + "react": ">=16" | |
| 852 | + } | |
| 853 | + }, | |
| 836 | 854 | "node_modules/@mdx-js/rollup": { |
| 837 | 855 | "version": "3.1.1", |
| 838 | 856 | "resolved": "https://registry.npmjs.org/@mdx-js/rollup/-/rollup-3.1.1.tgz", |
@@ -1420,7 +1438,6 @@ | ||
| 1420 | 1438 | "version": "2.0.14", |
| 1421 | 1439 | "resolved": "https://registry.npmjs.org/@types/mdx/-/mdx-2.0.14.tgz", |
| 1422 | 1440 | "integrity": "sha512-T48PeuJtvLosNTPVhfnIp3i/n3a4g4Bad7YCq5k64D4u7NwDrAotikQ+5+sjtUvBmxCMlbo3dVL+C2dP0rWHzg==", |
| 1423 | − "dev": true, | |
| 1424 | 1441 | "license": "MIT" |
| 1425 | 1442 | }, |
| 1426 | 1443 | "node_modules/@types/ms": { |
@@ -1436,6 +1453,16 @@ | ||
| 1436 | 1453 | "integrity": "sha512-vqlvI7qlMvcCBbVe0AKAb4f97//Hy0EBTaiW8AalRnG/xAN5zOiWWyrNqNXeq8+KAuvRewjCVY1+IPxk4RdNYw==", |
| 1437 | 1454 | "license": "MIT" |
| 1438 | 1455 | }, |
| 1456 | + "node_modules/@types/react": { | |
| 1457 | + "version": "19.2.18", | |
| 1458 | + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.18.tgz", | |
| 1459 | + "integrity": "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==", | |
| 1460 | + "license": "MIT", | |
| 1461 | + "peer": true, | |
| 1462 | + "dependencies": { | |
| 1463 | + "csstype": "^3.2.2" | |
| 1464 | + } | |
| 1465 | + }, | |
| 1439 | 1466 | "node_modules/@types/unist": { |
| 1440 | 1467 | "version": "3.0.3", |
| 1441 | 1468 | "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", |
@@ -1676,6 +1703,13 @@ | ||
| 1676 | 1703 | "dev": true, |
| 1677 | 1704 | "license": "MIT" |
| 1678 | 1705 | }, |
| 1706 | + "node_modules/csstype": { | |
| 1707 | + "version": "3.2.3", | |
| 1708 | + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", | |
| 1709 | + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", | |
| 1710 | + "license": "MIT", | |
| 1711 | + "peer": true | |
| 1712 | + }, | |
| 1679 | 1713 | "node_modules/debug": { |
| 1680 | 1714 | "version": "4.4.3", |
| 1681 | 1715 | "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", |
modified
hfmarketdata/web/package.json
+5 −4
@@ -12,16 +12,17 @@ | ||
| 12 | 12 | "test:e2e": "playwright test" |
| 13 | 13 | }, |
| 14 | 14 | "dependencies": { |
| 15 | + "@mdx-js/react": "^3.1.1", | |
| 16 | + "lightweight-charts": "^4.2.0", | |
| 17 | + "prism-react-renderer": "^2.4.0", | |
| 15 | 18 | "react": "^18.3.1", |
| 16 | 19 | "react-dom": "^18.3.1", |
| 17 | − "react-router-dom": "^6.28.0", | |
| 18 | − "lightweight-charts": "^4.2.0", | |
| 19 | − "prism-react-renderer": "^2.4.0" | |
| 20 | + "react-router-dom": "^6.28.0" | |
| 20 | 21 | }, |
| 21 | 22 | "devDependencies": { |
| 22 | − "@vitejs/plugin-react": "^4.3.4", | |
| 23 | 23 | "@mdx-js/rollup": "^3.1.0", |
| 24 | 24 | "@playwright/test": "^1.49.0", |
| 25 | + "@vitejs/plugin-react": "^4.3.4", | |
| 25 | 26 | "vite": "^6.0.0" |
| 26 | 27 | } |
| 27 | 28 | } |
added
hfmarketdata/web/public/favicon.svg
+6 −0
@@ -0,0 +1,6 @@ | ||
| 1 | +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64"> | |
| 2 | + <defs><linearGradient id="g" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="#5ee7a5"/><stop offset="1" stop-color="#4db8ff"/></linearGradient></defs> | |
| 3 | + <rect width="64" height="64" rx="14" fill="#0a0c10"/> | |
| 4 | + <path d="M10 44 L20 30 L28 36 L38 20 L46 26 L54 14" fill="none" stroke="url(#g)" stroke-width="5" stroke-linecap="round" stroke-linejoin="round"/> | |
| 5 | + <rect x="10" y="50" width="44" height="4" rx="2" fill="#5ee7a5" opacity=".5"/> | |
| 6 | +</svg> | |
added
hfmarketdata/web/public/logo.svg
+6 −0
@@ -0,0 +1,6 @@ | ||
| 1 | +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 320 64" width="320" height="64" role="img" aria-label="HF Market Data"> | |
| 2 | + <defs><linearGradient id="g" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="#5ee7a5"/><stop offset="1" stop-color="#4db8ff"/></linearGradient></defs> | |
| 3 | + <rect width="64" height="64" rx="14" fill="#0a0c10"/> | |
| 4 | + <path d="M10 44 L20 30 L28 36 L38 20 L46 26 L54 14" fill="none" stroke="url(#g)" stroke-width="5" stroke-linecap="round" stroke-linejoin="round"/> | |
| 5 | + <text x="80" y="41" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif" font-size="28" font-weight="650" fill="#e8ebf1" letter-spacing="-0.5">HF Market Data</text> | |
| 6 | +</svg> | |
added
hfmarketdata/web/public/og.svg
+16 −0
@@ -0,0 +1,16 @@ | ||
| 1 | +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1200 630" width="1200" height="630" role="img" aria-label="HF Market Data — Open High-Frequency Market Data API"> | |
| 2 | + <defs> | |
| 3 | + <linearGradient id="g" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="#5ee7a5"/><stop offset="1" stop-color="#4db8ff"/></linearGradient> | |
| 4 | + <linearGradient id="bg" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="#0a0c10"/><stop offset="1" stop-color="#141a24"/></linearGradient> | |
| 5 | + </defs> | |
| 6 | + <rect width="1200" height="630" fill="url(#bg)"/> | |
| 7 | + <g stroke="#232833" stroke-width="1"> | |
| 8 | + <path d="M0 105H1200M0 210H1200M0 315H1200M0 420H1200M0 525H1200"/> | |
| 9 | + </g> | |
| 10 | + <path d="M80 470 L200 380 L300 420 L420 300 L540 340 L660 220 L780 280 L900 160 L1020 200 L1120 110" fill="none" stroke="url(#g)" stroke-width="10" stroke-linecap="round" stroke-linejoin="round" opacity=".85"/> | |
| 11 | + <rect x="80" y="80" width="72" height="72" rx="16" fill="#0a0c10" stroke="#313847"/> | |
| 12 | + <path d="M92 130 L104 112 L114 120 L126 100 L136 108 L142 92" fill="none" stroke="url(#g)" stroke-width="6" stroke-linecap="round" stroke-linejoin="round"/> | |
| 13 | + <text x="172" y="132" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif" font-size="44" font-weight="700" fill="#e8ebf1" letter-spacing="-1">HF Market Data</text> | |
| 14 | + <text x="80" y="560" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif" font-size="34" fill="#aeb6c4">Open high-frequency market data API · 1-minute to daily since 2010</text> | |
| 15 | + <text x="80" y="600" font-family="ui-monospace, Menlo, Consolas, monospace" font-size="24" fill="#5ee7a5">curl https://www.hfmarketdata.io/v1/bars/stock/AAPL?timeframe=1min</text> | |
| 16 | +</svg> | |
added
hfmarketdata/web/public/robots.txt
+5 −0
@@ -0,0 +1,5 @@ | ||
| 1 | +User-agent: * | |
| 2 | +Allow: / | |
| 3 | +Disallow: /dashboard | |
| 4 | +Disallow: /admin | |
| 5 | +Sitemap: https://www.hfmarketdata.io/sitemap.xml | |
modified
hfmarketdata/web/src/app/Layout.jsx
+106 −10
@@ -1,20 +1,64 @@ | ||
| 1 | −import React from 'react' | |
| 2 | −import { Link, NavLink } from 'react-router-dom' | |
| 1 | +// App shell: top bar (nav · search · theme · session), mobile drawer, footer. Owner: web-core. | |
| 2 | +import React, { Suspense, lazy, useCallback, useEffect, useState } from 'react' | |
| 3 | +import { Link, NavLink, useLocation } from 'react-router-dom' | |
| 3 | 4 | import { useAuth } from './auth.jsx' |
| 4 | −import { CONTACT_EMAIL } from './api.js' | |
| 5 | +import { CONTACT_EMAIL, PUBLIC_BASE } from './api.js' | |
| 6 | +import { useTheme } from './theme.js' | |
| 7 | +import { CloseIcon, MenuIcon, MoonIcon, SearchIcon, SunIcon } from '../components/Icons.jsx' | |
| 5 | 8 | |
| 6 | −const NAV = [ | |
| 9 | +const SearchDialog = lazy(() => import('../components/SearchDialog.jsx')) | |
| 10 | + | |
| 11 | +/** Global shortcuts: Cmd/Ctrl+K opens search, `/` opens it too (outside form fields). */ | |
| 12 | +function useSearchShortcut(open) { | |
| 13 | + useEffect(() => { | |
| 14 | + const on = e => { | |
| 15 | + const tag = e.target?.tagName | |
| 16 | + const typing = tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || e.target?.isContentEditable | |
| 17 | + if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') { e.preventDefault(); open() } | |
| 18 | + else if (e.key === '/' && !typing && !e.metaKey && !e.ctrlKey && !e.altKey) { e.preventDefault(); open() } | |
| 19 | + } | |
| 20 | + window.addEventListener('keydown', on) | |
| 21 | + return () => window.removeEventListener('keydown', on) | |
| 22 | + }, [open]) | |
| 23 | +} | |
| 24 | + | |
| 25 | +export const NAV = [ | |
| 7 | 26 | ['/docs', 'Docs'], ['/playground', 'Playground'], ['/integrations', 'Integrations'], |
| 8 | 27 | ['/pricing', 'Pricing / Limits'], ['/status', 'Status'], |
| 9 | 28 | ] |
| 10 | 29 | |
| 30 | +export const REPO_URL = 'https://git.spboucher.ai/hfmarketdata.git' | |
| 31 | + | |
| 32 | +const isMac = typeof navigator !== 'undefined' && /Mac|iPhone|iPad/.test(navigator.platform || navigator.userAgent) | |
| 33 | + | |
| 11 | 34 | export default function Layout({ children }) { |
| 12 | 35 | const { user } = useAuth() |
| 36 | + const { theme, toggle } = useTheme() | |
| 37 | + const [drawer, setDrawer] = useState(false) | |
| 38 | + const [searchOpen, setSearchOpen] = useState(false) | |
| 39 | + const location = useLocation() | |
| 40 | + const openSearch = useCallback(() => setSearchOpen(true), []) | |
| 41 | + useSearchShortcut(openSearch) | |
| 42 | + useEffect(() => { setDrawer(false) }, [location.pathname]) | |
| 43 | + useEffect(() => { | |
| 44 | + if (!drawer) return | |
| 45 | + const onKey = e => { if (e.key === 'Escape') setDrawer(false) } | |
| 46 | + window.addEventListener('keydown', onKey) | |
| 47 | + return () => window.removeEventListener('keydown', onKey) | |
| 48 | + }, [drawer]) | |
| 49 | + // Scroll to top on route change (hash links handled by the docs pages). | |
| 50 | + useEffect(() => { if (!location.hash) window.scrollTo(0, 0) }, [location.pathname, location.hash]) | |
| 51 | + | |
| 52 | + const session = user | |
| 53 | + ? <Link to="/dashboard" className="btn btn-ghost">Dashboard</Link> | |
| 54 | + : <Link to="/signin" className="btn btn-ghost btn-signin">Sign in</Link> | |
| 55 | + | |
| 13 | 56 | return ( |
| 14 | 57 | <div className="shell"> |
| 58 | + <a href="#main" className="skip-link">Skip to content</a> | |
| 15 | 59 | <header className="topbar"> |
| 16 | 60 | <Link to="/" className="brand" aria-label="HF Market Data home"> |
| 17 | − <span className="brand-mark">HF</span><span className="brand-name">Market Data</span> | |
| 61 | + <span className="brand-mark" aria-hidden="true">HF</span><span className="brand-name">Market Data</span> | |
| 18 | 62 | </Link> |
| 19 | 63 | <nav className="topnav" aria-label="Main"> |
| 20 | 64 | {NAV.map(([to, label]) => ( |
@@ -22,15 +66,67 @@ export default function Layout({ children }) { | ||
| 22 | 66 | ))} |
| 23 | 67 | </nav> |
| 24 | 68 | <div className="topbar-right"> |
| 25 | − {user ? <Link to="/dashboard" className="btn btn-ghost">Dashboard</Link> | |
| 26 | − : <Link to="/signin" className="btn btn-ghost">Sign in</Link>} | |
| 69 | + <button type="button" className="search-trigger" onClick={openSearch} aria-label="Search documentation" data-testid="search-trigger"> | |
| 70 | + <SearchIcon /><span>Search docs…</span><kbd>{isMac ? '⌘' : 'Ctrl'} K</kbd> | |
| 71 | + </button> | |
| 72 | + <button type="button" className="icon-btn" onClick={toggle} aria-label={theme === 'dark' ? 'Switch to light theme' : 'Switch to dark theme'} data-testid="theme-toggle" title="Toggle theme"> | |
| 73 | + {theme === 'dark' ? <SunIcon /> : <MoonIcon />} | |
| 74 | + </button> | |
| 75 | + {session} | |
| 76 | + <button type="button" className="icon-btn menu-btn" onClick={() => setDrawer(true)} aria-label="Open menu" aria-expanded={drawer} aria-controls="mobile-drawer"><MenuIcon /></button> | |
| 27 | 77 | </div> |
| 28 | 78 | </header> |
| 29 | − {children} | |
| 79 | + | |
| 80 | + {drawer && ( | |
| 81 | + <> | |
| 82 | + <div className="drawer-backdrop" onClick={() => setDrawer(false)} /> | |
| 83 | + <nav className="drawer" id="mobile-drawer" aria-label="Mobile"> | |
| 84 | + <div className="drawer-head"> | |
| 85 | + <span className="brand"><span className="brand-mark" aria-hidden="true">HF</span>Market Data</span> | |
| 86 | + <button type="button" className="icon-btn" onClick={() => setDrawer(false)} aria-label="Close menu"><CloseIcon /></button> | |
| 87 | + </div> | |
| 88 | + <NavLink to="/" end className="navlink">Home</NavLink> | |
| 89 | + {NAV.map(([to, label]) => <NavLink key={to} to={to} className="navlink">{label}</NavLink>)} | |
| 90 | + <div className="drawer-foot"> | |
| 91 | + {user ? <Link to="/dashboard" className="btn">Dashboard</Link> : <><Link to="/signin" className="btn">Sign in</Link><Link to="/signup" className="btn btn-primary">Create free account</Link></>} | |
| 92 | + </div> | |
| 93 | + </nav> | |
| 94 | + </> | |
| 95 | + )} | |
| 96 | + | |
| 97 | + <div id="main" style={{ display: 'contents' }}>{children}</div> | |
| 98 | + | |
| 30 | 99 | <footer className="footer"> |
| 31 | − <div>© {new Date().getFullYear()} HF Market Data · Simon-Pierre Boucher · <a href={`mailto:${CONTACT_EMAIL}`}>{CONTACT_EMAIL}</a></div> | |
| 32 | − <div className="muted">Data: FirstRate Data · SEC EDGAR · UTC / ISO 8601 everywhere</div> | |
| 100 | + <div className="footer-inner"> | |
| 101 | + <div> | |
| 102 | + <div className="brand" style={{ marginBottom: 10 }}><span className="brand-mark" aria-hidden="true">HF</span>Market Data</div> | |
| 103 | + <p className="muted" style={{ maxWidth: 320 }}>Open high-frequency market data API. 1-minute to daily bars since 2010, futures contracts, options chains with Greeks, SEC EDGAR fundamentals.</p> | |
| 104 | + <p className="muted small">Base URL <code>{PUBLIC_BASE}</code></p> | |
| 105 | + </div> | |
| 106 | + <div> | |
| 107 | + <h4>Product</h4> | |
| 108 | + <ul><li><Link to="/docs">Documentation</Link></li><li><Link to="/docs/reference">API reference</Link></li><li><Link to="/playground">Playground</Link></li><li><Link to="/integrations">Integrations</Link></li><li><Link to="/pricing">Pricing / Limits</Link></li></ul> | |
| 109 | + </div> | |
| 110 | + <div> | |
| 111 | + <h4>Resources</h4> | |
| 112 | + <ul><li><Link to="/docs/quickstart">Quickstart</Link></li><li><Link to="/docs/changelog">Changelog</Link></li><li><Link to="/docs/errors">Error codes</Link></li><li><Link to="/status">Status</Link></li><li><a href={`${PUBLIC_BASE}/openapi.json`}>openapi.json</a></li></ul> | |
| 113 | + </div> | |
| 114 | + <div> | |
| 115 | + <h4>Contact</h4> | |
| 116 | + <ul><li><a href={`mailto:${CONTACT_EMAIL}`}>{CONTACT_EMAIL}</a></li><li><a href={REPO_URL} rel="noopener noreferrer">Source repository</a></li><li><Link to="/signup">Create free account</Link></li></ul> | |
| 117 | + </div> | |
| 118 | + </div> | |
| 119 | + <div className="footer-bottom"> | |
| 120 | + <span>© {new Date().getFullYear()} HF Market Data · Simon-Pierre Boucher</span> | |
| 121 | + <span>Data: FirstRate Data · SEC EDGAR · UTC / ISO 8601 everywhere</span> | |
| 122 | + </div> | |
| 33 | 123 | </footer> |
| 124 | + | |
| 125 | + {searchOpen && ( | |
| 126 | + <Suspense fallback={null}> | |
| 127 | + <SearchDialog open={searchOpen} onClose={() => setSearchOpen(false)} /> | |
| 128 | + </Suspense> | |
| 129 | + )} | |
| 34 | 130 | </div> |
| 35 | 131 | ) |
| 36 | 132 | } |
modified
hfmarketdata/web/src/app/theme.css
+135 −30
@@ -1,41 +1,146 @@ | ||
| 1 | −/* HF Market Data design tokens — dark by default, one accent, mono for code. Owner: web-core. */ | |
| 1 | +/* HF Market Data design tokens + shell. Dark by default, light via [data-theme="light"] (persisted in localStorage). | |
| 2 | + System font stacks only — no external font requests (LCP budget < 1 s). Owner: web-core. */ | |
| 2 | 3 | :root { |
| 3 | 4 | color-scheme: dark; |
| 4 | − --bg: #0b0d10; --bg-1: #12151a; --bg-2: #181c23; --line: #262b34; --line-2: #333a46; | |
| 5 | − --fg: #e6e9ef; --fg-1: #aeb6c2; --fg-2: #7c8594; | |
| 6 | − --accent: #5ee7a5; --accent-2: #37b3ff; --danger: #ff6b6b; --warn: #ffc857; | |
| 7 | − --mono: "JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; | |
| 8 | − --sans: Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; | |
| 9 | − --radius: 8px; --max: 1240px; | |
| 5 | + --bg: #0a0c10; --bg-1: #10131a; --bg-2: #161a22; --bg-3: #1d222c; | |
| 6 | + --line: #232833; --line-2: #313847; --line-3: #414a5c; | |
| 7 | + --fg: #e8ebf1; --fg-1: #aeb6c4; --fg-2: #7d8697; --fg-3: #5a6373; | |
| 8 | + --accent: #5ee7a5; --accent-ink: #04150c; --accent-soft: rgba(94, 231, 165, 0.12); | |
| 9 | + --accent-2: #4db8ff; --accent-2-soft: rgba(77, 184, 255, 0.12); | |
| 10 | + --danger: #ff6b6b; --danger-soft: rgba(255, 107, 107, 0.12); | |
| 11 | + --warn: #ffc857; --warn-soft: rgba(255, 200, 87, 0.12); | |
| 12 | + --get: #4db8ff; --post: #5ee7a5; --delete: #ff6b6b; --put: #ffc857; --patch: #c792ea; | |
| 13 | + --mono: ui-monospace, "SF Mono", SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace; | |
| 14 | + --sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; | |
| 15 | + --radius: 8px; --radius-lg: 14px; --max: 1240px; | |
| 16 | + --shadow: 0 1px 2px rgba(0, 0, 0, .3), 0 12px 40px -12px rgba(0, 0, 0, .55); | |
| 17 | + --topbar-h: 56px; | |
| 18 | + --code-bg: #0c0f15; --code-fg: #d9dee8; --code-comment: #6b7688; --code-key: #7fd1ff; --code-string: #9fe7c0; --code-number: #ffcf7a; --code-keyword: #d6a6ff; --code-punct: #8d97a8; --code-func: #9fd0ff; | |
| 10 | 19 | } |
| 11 | −[data-theme="light"] { color-scheme: light; --bg: #ffffff; --bg-1: #f6f7f9; --bg-2: #eef0f4; --line: #dfe3ea; --line-2: #c9cfd9; --fg: #14181f; --fg-1: #3d4654; --fg-2: #6b7482; --accent: #0f8f5a; --accent-2: #0b6fb8; } | |
| 12 | −* { box-sizing: border-box; } | |
| 13 | −html { font-family: var(--sans); background: var(--bg); color: var(--fg); -webkit-font-smoothing: antialiased; font-size: 15px; } | |
| 14 | −body { margin: 0; } | |
| 20 | +[data-theme="light"] { | |
| 21 | + color-scheme: light; | |
| 22 | + --bg: #ffffff; --bg-1: #f6f7f9; --bg-2: #eef0f4; --bg-3: #e4e7ed; | |
| 23 | + --line: #e2e5eb; --line-2: #cfd4dd; --line-3: #b4bcc9; | |
| 24 | + --fg: #12161d; --fg-1: #3b4452; --fg-2: #66707f; --fg-3: #8b94a3; | |
| 25 | + --accent: #0d8a55; --accent-ink: #ffffff; --accent-soft: rgba(13, 138, 85, 0.10); | |
| 26 | + --accent-2: #0b6fb8; --accent-2-soft: rgba(11, 111, 184, 0.10); | |
| 27 | + --danger: #d23b3b; --danger-soft: rgba(210, 59, 59, 0.10); | |
| 28 | + --warn: #b7791f; --warn-soft: rgba(183, 121, 31, 0.12); | |
| 29 | + --get: #0b6fb8; --post: #0d8a55; --delete: #d23b3b; --put: #b7791f; --patch: #7c3aed; | |
| 30 | + --shadow: 0 1px 2px rgba(20, 24, 31, .06), 0 12px 40px -16px rgba(20, 24, 31, .22); | |
| 31 | + --code-bg: #f4f6f9; --code-fg: #1d2430; --code-comment: #7a8494; --code-key: #0b5ea8; --code-string: #0d7a4a; --code-number: #a35a00; --code-keyword: #7c3aed; --code-punct: #5d6675; --code-func: #0b5ea8; | |
| 32 | +} | |
| 33 | + | |
| 34 | +/* ---- base ---- */ | |
| 35 | +*, *::before, *::after { box-sizing: border-box; } | |
| 36 | +html { font-family: var(--sans); background: var(--bg); color: var(--fg); -webkit-font-smoothing: antialiased; font-size: 15px; line-height: 1.55; text-rendering: optimizeLegibility; scroll-padding-top: calc(var(--topbar-h) + 16px); } | |
| 37 | +body { margin: 0; min-height: 100dvh; } | |
| 38 | +h1, h2, h3, h4 { line-height: 1.2; letter-spacing: -0.015em; margin: 0 0 0.5em; font-weight: 650; } | |
| 39 | +h1 { font-size: clamp(28px, 4vw, 40px); } | |
| 40 | +h2 { font-size: clamp(22px, 2.6vw, 28px); } | |
| 41 | +h3 { font-size: 18px; } | |
| 42 | +h4 { font-size: 15px; } | |
| 43 | +p { margin: 0 0 1em; } | |
| 15 | 44 | a { color: var(--accent-2); text-decoration: none; } |
| 16 | −a:hover { text-decoration: underline; } | |
| 17 | −code, pre, kbd, .mono { font-family: var(--mono); font-size: 0.92em; } | |
| 18 | −pre { background: var(--bg-1); border: 1px solid var(--line); border-radius: var(--radius); padding: 12px 14px; overflow: auto; } | |
| 45 | +a:hover { text-decoration: underline; text-underline-offset: 3px; } | |
| 46 | +a:focus-visible, button:focus-visible, [tabindex]:focus-visible, input:focus-visible, select:focus-visible, textarea:focus-visible, summary:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; border-radius: 4px; } | |
| 47 | +code, pre, kbd, .mono { font-family: var(--mono); font-size: 0.9em; } | |
| 48 | +code { background: var(--bg-2); border: 1px solid var(--line); border-radius: 5px; padding: 1px 5px; } | |
| 49 | +pre { background: var(--code-bg); border: 1px solid var(--line); border-radius: var(--radius); padding: 12px 14px; overflow: auto; line-height: 1.5; } | |
| 50 | +pre code { background: none; border: 0; padding: 0; font-size: 13px; } | |
| 51 | +kbd { background: var(--bg-2); border: 1px solid var(--line-2); border-bottom-width: 2px; border-radius: 5px; padding: 1px 6px; font-size: 12px; color: var(--fg-1); } | |
| 52 | +hr { border: 0; border-top: 1px solid var(--line); margin: 28px 0; } | |
| 53 | +img, svg { max-width: 100%; } | |
| 54 | +button { font: inherit; } | |
| 19 | 55 | .muted { color: var(--fg-2); } |
| 56 | +.small { font-size: 13px; } | |
| 57 | +.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; } | |
| 58 | +.skip-link { position: absolute; left: 12px; top: -48px; z-index: 100; background: var(--accent); color: var(--accent-ink); padding: 8px 12px; border-radius: 6px; font-weight: 600; } | |
| 59 | +.skip-link:focus { top: 12px; text-decoration: none; } | |
| 60 | + | |
| 61 | +/* ---- shell ---- */ | |
| 20 | 62 | .shell { min-height: 100dvh; display: flex; flex-direction: column; } |
| 21 | −.topbar { position: sticky; top: 0; z-index: 50; display: flex; align-items: center; gap: 24px; padding: 0 20px; height: 56px; background: color-mix(in srgb, var(--bg) 88%, transparent); backdrop-filter: blur(10px); border-bottom: 1px solid var(--line); } | |
| 22 | −.brand { display: inline-flex; align-items: center; gap: 8px; color: var(--fg); font-weight: 600; } | |
| 23 | −.brand-mark { font-family: var(--mono); background: var(--accent); color: #05140c; border-radius: 6px; padding: 2px 6px; font-size: 13px; } | |
| 24 | −.topnav { display: flex; gap: 4px; flex: 1; } | |
| 25 | −.topnav a { color: var(--fg-1); padding: 6px 10px; border-radius: 6px; } | |
| 63 | +.topbar { position: sticky; top: 0; z-index: 50; display: flex; align-items: center; gap: 20px; padding: 0 20px; height: var(--topbar-h); background: color-mix(in srgb, var(--bg) 86%, transparent); backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(12px); border-bottom: 1px solid var(--line); } | |
| 64 | +.brand { display: inline-flex; align-items: center; gap: 9px; color: var(--fg); font-weight: 650; letter-spacing: -0.01em; white-space: nowrap; } | |
| 65 | +.brand:hover { text-decoration: none; } | |
| 66 | +.brand-mark { display: inline-grid; place-items: center; width: 28px; height: 28px; border-radius: 7px; background: linear-gradient(135deg, var(--accent), color-mix(in srgb, var(--accent) 55%, var(--accent-2))); color: var(--accent-ink); font-family: var(--mono); font-size: 12px; font-weight: 700; } | |
| 67 | +.topnav { display: flex; gap: 2px; flex: 1; } | |
| 68 | +.topnav a { color: var(--fg-1); padding: 6px 10px; border-radius: 6px; font-weight: 500; font-size: 14px; } | |
| 26 | 69 | .topnav a.active, .topnav a:hover { color: var(--fg); background: var(--bg-2); text-decoration: none; } |
| 27 | −.btn { display: inline-flex; align-items: center; gap: 6px; padding: 8px 14px; border-radius: var(--radius); border: 1px solid var(--line-2); background: var(--bg-1); color: var(--fg); cursor: pointer; font: inherit; } | |
| 28 | −.btn:hover { border-color: var(--fg-2); text-decoration: none; } | |
| 29 | −.btn-primary { background: var(--accent); color: #05140c; border-color: var(--accent); font-weight: 600; } | |
| 30 | −.btn-ghost { background: transparent; } | |
| 70 | +.topbar-right { display: flex; align-items: center; gap: 8px; margin-left: auto; } | |
| 71 | +.search-trigger { display: inline-flex; align-items: center; gap: 10px; height: 34px; padding: 0 10px 0 12px; border-radius: 8px; border: 1px solid var(--line-2); background: var(--bg-1); color: var(--fg-2); cursor: pointer; font-size: 13px; min-width: 190px; } | |
| 72 | +.search-trigger:hover { border-color: var(--line-3); color: var(--fg-1); } | |
| 73 | +.search-trigger kbd { margin-left: auto; } | |
| 74 | +.icon-btn { display: inline-grid; place-items: center; width: 34px; height: 34px; border-radius: 8px; border: 1px solid transparent; background: transparent; color: var(--fg-1); cursor: pointer; } | |
| 75 | +.icon-btn:hover { background: var(--bg-2); color: var(--fg); } | |
| 76 | +.icon-btn svg { width: 18px; height: 18px; } | |
| 77 | +.menu-btn { display: none; } | |
| 78 | + | |
| 79 | +.btn { display: inline-flex; align-items: center; justify-content: center; gap: 7px; padding: 8px 14px; border-radius: var(--radius); border: 1px solid var(--line-2); background: var(--bg-1); color: var(--fg); cursor: pointer; font: inherit; font-weight: 500; font-size: 14px; line-height: 1.2; white-space: nowrap; transition: border-color .12s, background .12s, transform .06s; } | |
| 80 | +.btn:hover { border-color: var(--line-3); text-decoration: none; background: var(--bg-2); } | |
| 81 | +.btn:active { transform: translateY(1px); } | |
| 82 | +.btn-primary { background: var(--accent); color: var(--accent-ink); border-color: var(--accent); font-weight: 650; } | |
| 83 | +.btn-primary:hover { background: color-mix(in srgb, var(--accent) 88%, white); border-color: color-mix(in srgb, var(--accent) 88%, white); } | |
| 84 | +.btn-ghost { background: transparent; border-color: transparent; color: var(--fg-1); } | |
| 85 | +.btn-ghost:hover { background: var(--bg-2); color: var(--fg); border-color: transparent; } | |
| 86 | +.btn-lg { padding: 12px 20px; font-size: 15px; border-radius: 10px; } | |
| 87 | +.btn-sm { padding: 5px 10px; font-size: 13px; border-radius: 6px; } | |
| 88 | +.btn[disabled] { opacity: .55; cursor: not-allowed; } | |
| 89 | + | |
| 31 | 90 | .page { width: 100%; max-width: var(--max); margin: 0 auto; padding: 40px 20px 80px; flex: 1; } |
| 32 | −.page.narrow { max-width: 760px; } | |
| 91 | +.page.narrow { max-width: 780px; } | |
| 33 | 92 | .page-loading { flex: 1; min-height: 40vh; } |
| 34 | −.footer { border-top: 1px solid var(--line); padding: 24px 20px; display: flex; justify-content: space-between; gap: 16px; flex-wrap: wrap; color: var(--fg-1); font-size: 13px; } | |
| 35 | −.card { background: var(--bg-1); border: 1px solid var(--line); border-radius: var(--radius); padding: 18px; } | |
| 36 | −table { border-collapse: collapse; width: 100%; } | |
| 37 | −th, td { text-align: left; padding: 8px 10px; border-bottom: 1px solid var(--line); vertical-align: top; } | |
| 38 | −th { color: var(--fg-1); font-weight: 600; font-size: 13px; } | |
| 93 | +.footer { border-top: 1px solid var(--line); padding: 28px 20px 36px; color: var(--fg-1); font-size: 13px; } | |
| 94 | +.footer-inner { max-width: var(--max); margin: 0 auto; display: grid; grid-template-columns: 1.4fr repeat(3, 1fr); gap: 24px; } | |
| 95 | +.footer h4 { font-size: 12px; text-transform: uppercase; letter-spacing: .08em; color: var(--fg-2); margin-bottom: 10px; } | |
| 96 | +.footer ul { list-style: none; margin: 0; padding: 0; display: grid; gap: 6px; } | |
| 97 | +.footer a { color: var(--fg-1); } | |
| 98 | +.footer a:hover { color: var(--fg); } | |
| 99 | +.footer-bottom { max-width: var(--max); margin: 24px auto 0; padding-top: 16px; border-top: 1px solid var(--line); display: flex; justify-content: space-between; gap: 12px; flex-wrap: wrap; color: var(--fg-2); font-size: 12.5px; } | |
| 100 | + | |
| 101 | +.card { background: var(--bg-1); border: 1px solid var(--line); border-radius: var(--radius-lg); padding: 20px; } | |
| 102 | +.grid { display: grid; gap: 16px; } | |
| 103 | +.grid-2 { grid-template-columns: repeat(2, minmax(0, 1fr)); } | |
| 104 | +.grid-3 { grid-template-columns: repeat(3, minmax(0, 1fr)); } | |
| 105 | +.grid-4 { grid-template-columns: repeat(4, minmax(0, 1fr)); } | |
| 106 | +.eyebrow { font-size: 12px; font-weight: 650; letter-spacing: .1em; text-transform: uppercase; color: var(--accent); margin-bottom: 10px; } | |
| 107 | +.lead { font-size: 17px; color: var(--fg-1); max-width: 62ch; } | |
| 108 | + | |
| 109 | +table { border-collapse: collapse; width: 100%; font-size: 14px; } | |
| 110 | +th, td { text-align: left; padding: 9px 12px; border-bottom: 1px solid var(--line); vertical-align: top; } | |
| 111 | +th { color: var(--fg-1); font-weight: 600; font-size: 12.5px; text-transform: uppercase; letter-spacing: .04em; } | |
| 112 | +tbody tr:hover { background: color-mix(in srgb, var(--bg-2) 60%, transparent); } | |
| 113 | +.table-wrap { overflow-x: auto; border: 1px solid var(--line); border-radius: var(--radius); } | |
| 114 | +.table-wrap table { min-width: 520px; } | |
| 115 | +.table-wrap th { background: var(--bg-1); position: sticky; top: 0; } | |
| 116 | +.table-wrap tr:last-child td { border-bottom: 0; } | |
| 117 | +.num { text-align: right; font-variant-numeric: tabular-nums; font-family: var(--mono); font-size: 13px; } | |
| 118 | + | |
| 39 | 119 | input, select, textarea { font: inherit; color: var(--fg); background: var(--bg-1); border: 1px solid var(--line-2); border-radius: 6px; padding: 8px 10px; } |
| 40 | 120 | input:focus, select:focus, textarea:focus { outline: 2px solid color-mix(in srgb, var(--accent) 50%, transparent); border-color: var(--accent); } |
| 41 | −@media (max-width: 800px) { .topnav { display: none; } .topbar { gap: 12px; } } | |
| 121 | +::selection { background: color-mix(in srgb, var(--accent) 35%, transparent); } | |
| 122 | +@media (prefers-reduced-motion: reduce) { *, *::before, *::after { animation-duration: 0.01ms !important; transition-duration: 0.01ms !important; } } | |
| 123 | + | |
| 124 | +/* ---- mobile drawer ---- */ | |
| 125 | +.drawer-backdrop { position: fixed; inset: 0; z-index: 60; background: rgba(0, 0, 0, .55); } | |
| 126 | +.drawer { position: fixed; top: 0; right: 0; bottom: 0; z-index: 61; width: min(320px, 86vw); background: var(--bg-1); border-left: 1px solid var(--line); padding: 16px; display: flex; flex-direction: column; gap: 6px; box-shadow: var(--shadow); } | |
| 127 | +.drawer-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 8px; } | |
| 128 | +.drawer a.navlink { display: block; padding: 10px 12px; border-radius: 8px; color: var(--fg-1); font-weight: 500; } | |
| 129 | +.drawer a.navlink.active, .drawer a.navlink:hover { background: var(--bg-2); color: var(--fg); text-decoration: none; } | |
| 130 | +.drawer-foot { margin-top: auto; display: grid; gap: 8px; } | |
| 131 | + | |
| 132 | +@media (max-width: 900px) { | |
| 133 | + .topnav { display: none; } | |
| 134 | + .menu-btn { display: inline-grid; } | |
| 135 | + .search-trigger { min-width: 0; padding: 0 9px; } | |
| 136 | + .search-trigger span, .search-trigger kbd { display: none; } | |
| 137 | + .topbar { gap: 12px; padding: 0 14px; } | |
| 138 | + .footer-inner { grid-template-columns: 1fr 1fr; } | |
| 139 | + .grid-3, .grid-4 { grid-template-columns: 1fr 1fr; } | |
| 140 | + .page { padding: 28px 16px 64px; } | |
| 141 | +} | |
| 142 | +@media (max-width: 600px) { | |
| 143 | + .grid-2, .grid-3, .grid-4 { grid-template-columns: 1fr; } | |
| 144 | + .footer-inner { grid-template-columns: 1fr; } | |
| 145 | + .topbar-right .btn-signin { display: none; } | |
| 146 | +} | |
added
hfmarketdata/web/src/app/theme.js
+41 −0
@@ -0,0 +1,41 @@ | ||
| 1 | +// Theme (dark | light) persisted in localStorage under `hfmd.theme`. index.html applies it before first paint. | |
| 2 | +import { useCallback, useEffect, useState } from 'react' | |
| 3 | + | |
| 4 | +const KEY = 'hfmd.theme' | |
| 5 | + | |
| 6 | +export function getTheme() { | |
| 7 | + if (typeof document === 'undefined') return 'dark' | |
| 8 | + return document.documentElement.getAttribute('data-theme') === 'light' ? 'light' : 'dark' | |
| 9 | +} | |
| 10 | + | |
| 11 | +export function applyTheme(theme) { | |
| 12 | + document.documentElement.setAttribute('data-theme', theme) | |
| 13 | + try { localStorage.setItem(KEY, theme) } catch { /* private mode */ } | |
| 14 | + window.dispatchEvent(new CustomEvent('hfmd:theme', { detail: theme })) | |
| 15 | +} | |
| 16 | + | |
| 17 | +export function useTheme() { | |
| 18 | + const [theme, setTheme] = useState(getTheme) | |
| 19 | + useEffect(() => { | |
| 20 | + const on = e => setTheme(e.detail) | |
| 21 | + window.addEventListener('hfmd:theme', on) | |
| 22 | + return () => window.removeEventListener('hfmd:theme', on) | |
| 23 | + }, []) | |
| 24 | + const toggle = useCallback(() => applyTheme(getTheme() === 'dark' ? 'light' : 'dark'), []) | |
| 25 | + return { theme, toggle, set: applyTheme } | |
| 26 | +} | |
| 27 | + | |
| 28 | +// Small persisted-state helper reused by LangSelector, docs panel, etc. | |
| 29 | +export function usePersisted(key, initial) { | |
| 30 | + const [v, setV] = useState(() => { | |
| 31 | + try { const raw = localStorage.getItem(key); return raw == null ? initial : JSON.parse(raw) } catch { return initial } | |
| 32 | + }) | |
| 33 | + const set = useCallback(next => { | |
| 34 | + setV(prev => { | |
| 35 | + const val = typeof next === 'function' ? next(prev) : next | |
| 36 | + try { localStorage.setItem(key, JSON.stringify(val)) } catch { /* ignore */ } | |
| 37 | + return val | |
| 38 | + }) | |
| 39 | + }, [key]) | |
| 40 | + return [v, set] | |
| 41 | +} | |
added
hfmarketdata/web/src/components/Badge.jsx
+17 −0
@@ -0,0 +1,17 @@ | ||
| 1 | +// <Badge> generic pill · <MethodBadge method="GET"> coloured HTTP verb · <StatusBadge code={404}>. | |
| 2 | +import React from 'react' | |
| 3 | + | |
| 4 | +export default function Badge({ tone = 'neutral', children, className = '', title }) { | |
| 5 | + return <span className={`badge badge-${tone} ${className}`} title={title}>{children}</span> | |
| 6 | +} | |
| 7 | + | |
| 8 | +export function MethodBadge({ method = 'GET', className = '' }) { | |
| 9 | + const m = method.toUpperCase() | |
| 10 | + return <span className={`method method-${m.toLowerCase()} ${className}`}>{m}</span> | |
| 11 | +} | |
| 12 | + | |
| 13 | +export function StatusBadge({ code }) { | |
| 14 | + const n = Number(code) | |
| 15 | + const tone = n < 300 ? 'success' : n < 400 ? 'info' : n < 500 ? 'warn' : 'danger' | |
| 16 | + return <Badge tone={tone} className="mono">{code}</Badge> | |
| 17 | +} | |
added
hfmarketdata/web/src/components/Callout.jsx
+18 −0
@@ -0,0 +1,18 @@ | ||
| 1 | +// <Callout type="info|tip|warning|danger" title?> — highlighted aside. | |
| 2 | +import React from 'react' | |
| 3 | +import { BoltIcon, InfoIcon, WarnIcon } from './Icons.jsx' | |
| 4 | + | |
| 5 | +const ICONS = { info: InfoIcon, tip: BoltIcon, warning: WarnIcon, danger: WarnIcon } | |
| 6 | + | |
| 7 | +export default function Callout({ type = 'info', title, children, className = '' }) { | |
| 8 | + const Icon = ICONS[type] || InfoIcon | |
| 9 | + return ( | |
| 10 | + <aside className={`callout callout-${type} ${className}`} role={type === 'danger' || type === 'warning' ? 'alert' : 'note'}> | |
| 11 | + <span className="callout-icon"><Icon /></span> | |
| 12 | + <div className="callout-body"> | |
| 13 | + {title && <div className="callout-title">{title}</div>} | |
| 14 | + <div>{children}</div> | |
| 15 | + </div> | |
| 16 | + </aside> | |
| 17 | + ) | |
| 18 | +} | |
added
hfmarketdata/web/src/components/Code.jsx
+110 −0
@@ -0,0 +1,110 @@ | ||
| 1 | +// <Code> — syntax-highlighted block (prism-react-renderer, bundled grammars + tiny bash/R grammars) with a copy button. | |
| 2 | +// Colors come from CSS variables so it follows the dark/light theme without a second stylesheet. | |
| 3 | +import React, { useCallback, useEffect, useState } from 'react' | |
| 4 | +import { Highlight, Prism } from 'prism-react-renderer' | |
| 5 | +import { CheckIcon, CopyIcon } from './Icons.jsx' | |
| 6 | + | |
| 7 | +// prism-react-renderer ships no bash/R grammar — minimal ones, enough for API snippets. | |
| 8 | +if (!Prism.languages.bash) { | |
| 9 | + Prism.languages.bash = { | |
| 10 | + comment: /#.*/, | |
| 11 | + string: [{ pattern: /"(?:\\[\s\S]|\$\([^)]*\)|[^"\\])*"/, greedy: true, inside: { variable: /\$\{?[\w.]+\}?/ } }, { pattern: /'[^']*'/, greedy: true }], | |
| 12 | + variable: /\$\{?[\w.]+\}?/, | |
| 13 | + flag: { pattern: /(^|\s)--?[\w-]+/, lookbehind: true, alias: 'attr-name' }, | |
| 14 | + function: /\b(?:curl|jq|python3?|pip|npm|npx|node|export|echo|cat|head|wget|Rscript)\b/, | |
| 15 | + operator: /\\$|\||&&|>/m, | |
| 16 | + punctuation: /[{}()]/, | |
| 17 | + } | |
| 18 | + Prism.languages.sh = Prism.languages.shell = Prism.languages.bash | |
| 19 | +} | |
| 20 | +if (!Prism.languages.r) { | |
| 21 | + Prism.languages.r = { | |
| 22 | + comment: /#.*/, | |
| 23 | + string: { pattern: /(["'])(?:\\.|(?!\1)[^\\\r\n])*\1/, greedy: true }, | |
| 24 | + 'percent-operator': { pattern: /%[^%\s]*%/, alias: 'operator' }, | |
| 25 | + boolean: /\b(?:TRUE|FALSE|T|F)\b/, | |
| 26 | + keyword: /\b(?:NA|NULL|Inf|NaN|if|else|for|while|repeat|function|return|in|next|break|library|require)\b/, | |
| 27 | + number: /\b(?:0x[\dA-Fa-f]+|\d*\.?\d+(?:e[+-]?\d+)?)[iL]?\b/i, | |
| 28 | + function: /\b[a-zA-Z_.][\w.]*(?=\s*\()/, | |
| 29 | + operator: /->?>?|<(?:=|<?-)?|[>=!]=?|::?|&&?|\|\|?|[+*\/^$@~]/, | |
| 30 | + punctuation: /[(){}\[\],;]/, | |
| 31 | + } | |
| 32 | +} | |
| 33 | + | |
| 34 | +const THEME = { | |
| 35 | + plain: { color: 'var(--code-fg)', backgroundColor: 'transparent' }, | |
| 36 | + styles: [ | |
| 37 | + { types: ['comment', 'prolog', 'doctype', 'cdata'], style: { color: 'var(--code-comment)', fontStyle: 'italic' } }, | |
| 38 | + { types: ['punctuation', 'operator'], style: { color: 'var(--code-punct)' } }, | |
| 39 | + { types: ['property', 'attr-name', 'variable', 'flag'], style: { color: 'var(--code-key)' } }, | |
| 40 | + { types: ['string', 'char', 'url', 'inserted'], style: { color: 'var(--code-string)' } }, | |
| 41 | + { types: ['number', 'boolean', 'constant', 'symbol'], style: { color: 'var(--code-number)' } }, | |
| 42 | + { types: ['keyword', 'builtin', 'important', 'atrule', 'tag'], style: { color: 'var(--code-keyword)' } }, | |
| 43 | + { types: ['function', 'class-name', 'percent-operator'], style: { color: 'var(--code-func)' } }, | |
| 44 | + { types: ['deleted'], style: { color: 'var(--danger)' } }, | |
| 45 | + ], | |
| 46 | +} | |
| 47 | + | |
| 48 | +export function useCopy(timeout = 1600) { | |
| 49 | + const [copied, setCopied] = useState(false) | |
| 50 | + const copy = useCallback(async text => { | |
| 51 | + try { | |
| 52 | + await navigator.clipboard.writeText(text) | |
| 53 | + } catch { | |
| 54 | + const ta = document.createElement('textarea'); ta.value = text; ta.setAttribute('readonly', '') | |
| 55 | + ta.style.position = 'fixed'; ta.style.opacity = '0'; document.body.appendChild(ta); ta.select() | |
| 56 | + try { document.execCommand('copy') } finally { document.body.removeChild(ta) } | |
| 57 | + } | |
| 58 | + setCopied(true) | |
| 59 | + }, []) | |
| 60 | + useEffect(() => { if (!copied) return; const t = setTimeout(() => setCopied(false), timeout); return () => clearTimeout(t) }, [copied, timeout]) | |
| 61 | + return { copied, copy } | |
| 62 | +} | |
| 63 | + | |
| 64 | +export function CopyButton({ text, label = 'Copy to clipboard', className = '' }) { | |
| 65 | + const { copied, copy } = useCopy() | |
| 66 | + return ( | |
| 67 | + <button type="button" className={`copy-btn ${copied ? 'copied' : ''} ${className}`} onClick={() => copy(text)} aria-label={copied ? 'Copied' : label} title={label}> | |
| 68 | + {copied ? <CheckIcon /> : <CopyIcon />}<span>{copied ? 'Copied' : 'Copy'}</span> | |
| 69 | + </button> | |
| 70 | + ) | |
| 71 | +} | |
| 72 | + | |
| 73 | +/** | |
| 74 | + * Code block. | |
| 75 | + * @param {string} code source text | |
| 76 | + * @param {string} language bash | python | javascript | r | json | http | text … | |
| 77 | + * @param {string} [title] optional header label (e.g. file name / "Response") | |
| 78 | + * @param {boolean} [copy=true] | |
| 79 | + * @param {number} [maxHeight] px — scrolls inside when longer | |
| 80 | + * @param {boolean} [lineNumbers=false] | |
| 81 | + */ | |
| 82 | +export default function Code({ code = '', language = 'text', title, copy = true, maxHeight, lineNumbers = false, className = '', children }) { | |
| 83 | + const src = (code || children || '').toString().replace(/\n$/, '') | |
| 84 | + const lang = Prism.languages[language] ? language : 'text' | |
| 85 | + return ( | |
| 86 | + <div className={`code ${className}`} data-language={language}> | |
| 87 | + {(title || copy) && ( | |
| 88 | + <div className="code-head"> | |
| 89 | + <span className="code-title">{title || language}</span> | |
| 90 | + {copy && <CopyButton text={src} />} | |
| 91 | + </div> | |
| 92 | + )} | |
| 93 | + <Highlight code={src} language={lang} theme={THEME}> | |
| 94 | + {({ className: cls, tokens, getLineProps, getTokenProps }) => ( | |
| 95 | + <pre className={`${cls} code-pre`} style={maxHeight ? { maxHeight } : undefined} tabIndex={0}> | |
| 96 | + <code> | |
| 97 | + {tokens.map((line, i) => ( | |
| 98 | + <span key={i} {...getLineProps({ line })} className="code-line"> | |
| 99 | + {lineNumbers && <span className="code-ln" aria-hidden="true">{i + 1}</span>} | |
| 100 | + {line.map((token, k) => <span key={k} {...getTokenProps({ token })} />)} | |
| 101 | + {'\n'} | |
| 102 | + </span> | |
| 103 | + ))} | |
| 104 | + </code> | |
| 105 | + </pre> | |
| 106 | + )} | |
| 107 | + </Highlight> | |
| 108 | + </div> | |
| 109 | + ) | |
| 110 | +} | |
added
hfmarketdata/web/src/components/CodeTabs.jsx
+26 −0
@@ -0,0 +1,26 @@ | ||
| 1 | +// <CodeTabs> — one code block per language, driven by the global persisted language (LangSelector). | |
| 2 | +// snippets: { curl, python, javascript, r } (any subset) or an array [{ id, label, language, code }]. | |
| 3 | +import React from 'react' | |
| 4 | +import Code from './Code.jsx' | |
| 5 | +import LangSelector, { LANGS, useLang } from './LangSelector.jsx' | |
| 6 | + | |
| 7 | +export default function CodeTabs({ snippets, title, maxHeight, className = '' }) { | |
| 8 | + const [lang, setLang] = useLang() | |
| 9 | + const items = Array.isArray(snippets) | |
| 10 | + ? snippets | |
| 11 | + : LANGS.filter(l => snippets?.[l.id] != null).map(l => ({ id: l.id, label: l.label, language: l.prism, code: snippets[l.id] })) | |
| 12 | + if (!items.length) return null | |
| 13 | + const active = items.find(s => s.id === lang) || items[0] | |
| 14 | + const value = items.some(s => s.id === lang) ? lang : active.id | |
| 15 | + return ( | |
| 16 | + <div className={`codetabs ${className}`}> | |
| 17 | + <div className="codetabs-head"> | |
| 18 | + {title && <span className="code-title">{title}</span>} | |
| 19 | + {Array.isArray(snippets) | |
| 20 | + ? <div className="lang-selector lang-sm" role="radiogroup" aria-label="Code sample">{items.map(s => <button key={s.id} type="button" role="radio" aria-checked={s.id === value} className={`lang-btn ${s.id === value ? 'active' : ''}`} onClick={() => setLang(s.id)}>{s.label}</button>)}</div> | |
| 21 | + : <LangSelector value={value} onChange={setLang} />} | |
| 22 | + </div> | |
| 23 | + <Code code={active.code} language={active.language} maxHeight={maxHeight} title={null} /> | |
| 24 | + </div> | |
| 25 | + ) | |
| 26 | +} | |
added
hfmarketdata/web/src/components/Icons.jsx
+30 −0
@@ -0,0 +1,30 @@ | ||
| 1 | +// Inline SVG icons (no icon font, no external request). All 24×24, stroke-based, currentColor. | |
| 2 | +import React from 'react' | |
| 3 | + | |
| 4 | +const I = ({ children, ...p }) => ( | |
| 5 | + <svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true" {...p}>{children}</svg> | |
| 6 | +) | |
| 7 | + | |
| 8 | +export const SunIcon = p => <I {...p}><circle cx="12" cy="12" r="4" /><path d="M12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4" /></I> | |
| 9 | +export const MoonIcon = p => <I {...p}><path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z" /></I> | |
| 10 | +export const SearchIcon = p => <I {...p}><circle cx="11" cy="11" r="7" /><path d="m20 20-3.5-3.5" /></I> | |
| 11 | +export const MenuIcon = p => <I {...p}><path d="M4 7h16M4 12h16M4 17h16" /></I> | |
| 12 | +export const CloseIcon = p => <I {...p}><path d="M6 6l12 12M18 6 6 18" /></I> | |
| 13 | +export const CopyIcon = p => <I {...p}><rect x="9" y="9" width="11" height="11" rx="2" /><path d="M5 15V6a2 2 0 0 1 2-2h9" /></I> | |
| 14 | +export const CheckIcon = p => <I {...p}><path d="m5 12 5 5L20 7" /></I> | |
| 15 | +export const ArrowRightIcon = p => <I {...p}><path d="M5 12h14M13 6l6 6-6 6" /></I> | |
| 16 | +export const ExternalIcon = p => <I {...p}><path d="M14 4h6v6M20 4l-9 9M19 14v5a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V6a1 1 0 0 1 1-1h5" /></I> | |
| 17 | +export const PlayIcon = p => <I {...p}><path d="M7 5v14l11-7z" fill="currentColor" stroke="none" /></I> | |
| 18 | +export const InfoIcon = p => <I {...p}><circle cx="12" cy="12" r="9" /><path d="M12 11v5M12 8h.01" /></I> | |
| 19 | +export const WarnIcon = p => <I {...p}><path d="M12 3 2.5 20h19L12 3zM12 10v4M12 17.5h.01" /></I> | |
| 20 | +export const BoltIcon = p => <I {...p}><path d="M13 2 4 14h7l-1 8 9-12h-7l1-8z" /></I> | |
| 21 | +export const ChevronIcon = p => <I {...p}><path d="m9 6 6 6-6 6" /></I> | |
| 22 | +export const ChevronDownIcon = p => <I {...p}><path d="m6 9 6 6 6-6" /></I> | |
| 23 | +export const KeyIcon = p => <I {...p}><circle cx="8" cy="15" r="4" /><path d="m10.9 12.1 8.6-8.6M15 6l3 3M18 3l3 3" /></I> | |
| 24 | +export const MailIcon = p => <I {...p}><rect x="3" y="5" width="18" height="14" rx="2" /><path d="m3 7 9 6 9-6" /></I> | |
| 25 | +export const DatabaseIcon = p => <I {...p}><ellipse cx="12" cy="5" rx="8" ry="3" /><path d="M4 5v14c0 1.7 3.6 3 8 3s8-1.3 8-3V5M4 12c0 1.7 3.6 3 8 3s8-1.3 8-3" /></I> | |
| 26 | +export const BookIcon = p => <I {...p}><path d="M4 5a2 2 0 0 1 2-2h13v16H6a2 2 0 0 0-2 2V5zM4 19a2 2 0 0 0 2 2h13" /></I> | |
| 27 | +export const TerminalIcon = p => <I {...p}><path d="m5 7 5 5-5 5M12 17h7" /></I> | |
| 28 | +export const EditIcon = p => <I {...p}><path d="M4 20h4l10.5-10.5a2 2 0 0 0 0-2.8l-1.2-1.2a2 2 0 0 0-2.8 0L4 16v4z" /></I> | |
| 29 | +export const GitIcon = p => <I {...p}><circle cx="6" cy="6" r="2.5" /><circle cx="6" cy="18" r="2.5" /><circle cx="18" cy="8" r="2.5" /><path d="M6 8.5v7M18 10.5c0 3-3 4-6 4.5s-6 1-6 1" /></I> | |
| 30 | +export const PulseIcon = p => <I {...p}><path d="M3 12h4l3-7 4 14 3-7h4" /></I> | |
added
hfmarketdata/web/src/components/LangSelector.jsx
+32 −0
@@ -0,0 +1,32 @@ | ||
| 1 | +// Language choice for code samples — one global, persisted value (`hfmd.lang`) shared by docs, guides and playground. | |
| 2 | +import React, { createContext, useContext } from 'react' | |
| 3 | +import { usePersisted } from '../app/theme.js' | |
| 4 | + | |
| 5 | +export const LANGS = [ | |
| 6 | + { id: 'curl', label: 'curl', prism: 'bash' }, | |
| 7 | + { id: 'python', label: 'Python', prism: 'python' }, | |
| 8 | + { id: 'javascript', label: 'JavaScript', prism: 'javascript' }, | |
| 9 | + { id: 'r', label: 'R', prism: 'r' }, | |
| 10 | +] | |
| 11 | + | |
| 12 | +const Ctx = createContext(['curl', () => {}]) | |
| 13 | + | |
| 14 | +export function LangProvider({ children }) { | |
| 15 | + const state = usePersisted('hfmd.lang', 'curl') | |
| 16 | + return <Ctx.Provider value={state}>{children}</Ctx.Provider> | |
| 17 | +} | |
| 18 | + | |
| 19 | +export const useLang = () => useContext(Ctx) | |
| 20 | + | |
| 21 | +export default function LangSelector({ value, onChange, size = 'sm', className = '', ariaLabel = 'Code language' }) { | |
| 22 | + const [ctxLang, setCtxLang] = useLang() | |
| 23 | + const lang = value ?? ctxLang | |
| 24 | + const set = onChange ?? setCtxLang | |
| 25 | + return ( | |
| 26 | + <div className={`lang-selector lang-${size} ${className}`} role="radiogroup" aria-label={ariaLabel}> | |
| 27 | + {LANGS.map(l => ( | |
| 28 | + <button key={l.id} type="button" role="radio" aria-checked={lang === l.id} className={`lang-btn ${lang === l.id ? 'active' : ''}`} onClick={() => set(l.id)}>{l.label}</button> | |
| 29 | + ))} | |
| 30 | + </div> | |
| 31 | + ) | |
| 32 | +} | |
added
hfmarketdata/web/src/components/Markdown.jsx
+136 −0
@@ -0,0 +1,136 @@ | ||
| 1 | +// <Markdown> — small, safe Markdown → React renderer for OpenAPI descriptions (no innerHTML, no raw HTML passthrough). | |
| 2 | +// Supports: ATX headings, paragraphs, bullet/numbered lists (one nesting level), fenced code, blockquotes, | |
| 3 | +// GFM tables, horizontal rules; inline code, **bold**, *italic*, [links](href), autolinks. Unsafe URL schemes are dropped. | |
| 4 | +import React from 'react' | |
| 5 | +import { Link } from 'react-router-dom' | |
| 6 | +import Code from './Code.jsx' | |
| 7 | + | |
| 8 | +const SAFE_HREF = /^(https?:|mailto:|\/|#|\.\.?\/)/i | |
| 9 | + | |
| 10 | +export function InlineMd({ text }) { | |
| 11 | + return <>{renderInline(text || '')}</> | |
| 12 | +} | |
| 13 | + | |
| 14 | +function renderInline(text, keyBase = 'i') { | |
| 15 | + const out = [] | |
| 16 | + let rest = text | |
| 17 | + let k = 0 | |
| 18 | + // token regex: code, bold, italic, link, autolink | |
| 19 | + const re = /(`+)([^`]|[^`][\s\S]*?[^`])\1(?!`)|\*\*([^*]+)\*\*|__([^_]+)__|\*([^*\s][^*]*?)\*|_([^_\s][^_]*?)_|\[([^\]]+)\]\(([^)\s]+)\)|<?(https?:\/\/[^\s<>)]+)>?/ | |
| 20 | + while (rest.length) { | |
| 21 | + const m = re.exec(rest) | |
| 22 | + if (!m) { out.push(rest); break } | |
| 23 | + if (m.index > 0) out.push(rest.slice(0, m.index)) | |
| 24 | + const key = `${keyBase}${k++}` | |
| 25 | + if (m[2] !== undefined) out.push(<code key={key}>{m[2]}</code>) | |
| 26 | + else if (m[3] !== undefined || m[4] !== undefined) out.push(<strong key={key}>{renderInline(m[3] ?? m[4], key)}</strong>) | |
| 27 | + else if (m[5] !== undefined || m[6] !== undefined) out.push(<em key={key}>{renderInline(m[5] ?? m[6], key)}</em>) | |
| 28 | + else if (m[7] !== undefined) out.push(<MdLink key={key} href={m[8]}>{renderInline(m[7], key)}</MdLink>) | |
| 29 | + else if (m[9] !== undefined) out.push(<MdLink key={key} href={m[9]}>{m[9]}</MdLink>) | |
| 30 | + rest = rest.slice(m.index + m[0].length) | |
| 31 | + } | |
| 32 | + return out | |
| 33 | +} | |
| 34 | + | |
| 35 | +export function MdLink({ href, children }) { | |
| 36 | + if (!SAFE_HREF.test(href)) return <span>{children}</span> | |
| 37 | + const site = /^https?:\/\/(www\.)?hfmarketdata\.io/i | |
| 38 | + let h = href | |
| 39 | + if (site.test(h)) h = h.replace(site, '') || '/' | |
| 40 | + if (h.startsWith('/') && !h.startsWith('//')) return <Link to={h}>{children}</Link> | |
| 41 | + const external = /^https?:/i.test(h) | |
| 42 | + return <a href={h} target={external ? '_blank' : undefined} rel={external ? 'noopener noreferrer' : undefined}>{children}</a> | |
| 43 | +} | |
| 44 | + | |
| 45 | +function slug(s) { return s.toLowerCase().replace(/[`*_]/g, '').replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '') } | |
| 46 | + | |
| 47 | +export default function Markdown({ children, text, className = '', headingOffset = 0 }) { | |
| 48 | + const src = (text ?? children ?? '').toString().replace(/\r\n/g, '\n') | |
| 49 | + const lines = src.split('\n') | |
| 50 | + const blocks = [] | |
| 51 | + let i = 0 | |
| 52 | + const para = [] | |
| 53 | + const flushPara = () => { if (para.length) { blocks.push(<p key={blocks.length}>{renderInline(para.join(' '), `p${blocks.length}`)}</p>); para.length = 0 } } | |
| 54 | + while (i < lines.length) { | |
| 55 | + const line = lines[i] | |
| 56 | + if (/^\s*$/.test(line)) { flushPara(); i++; continue } | |
| 57 | + const fence = /^\s*(```|~~~)\s*(\w+)?/.exec(line) | |
| 58 | + if (fence) { | |
| 59 | + flushPara() | |
| 60 | + const body = []; i++ | |
| 61 | + while (i < lines.length && !lines[i].startsWith(fence[1])) body.push(lines[i++]) | |
| 62 | + i++ | |
| 63 | + blocks.push(<Code key={blocks.length} code={body.join('\n')} language={fence[2] || 'text'} copy={body.length > 1} />) | |
| 64 | + continue | |
| 65 | + } | |
| 66 | + const h = /^(#{1,6})\s+(.*)$/.exec(line) | |
| 67 | + if (h) { | |
| 68 | + flushPara() | |
| 69 | + const level = Math.min(6, h[1].length + headingOffset) | |
| 70 | + const Tag = `h${level}` | |
| 71 | + blocks.push(<Tag key={blocks.length} id={slug(h[2])}>{renderInline(h[2], `h${blocks.length}`)}</Tag>) | |
| 72 | + i++; continue | |
| 73 | + } | |
| 74 | + if (/^\s*(-{3,}|\*{3,})\s*$/.test(line)) { flushPara(); blocks.push(<hr key={blocks.length} />); i++; continue } | |
| 75 | + if (/^\s*>/.test(line)) { | |
| 76 | + flushPara() | |
| 77 | + const q = [] | |
| 78 | + while (i < lines.length && /^\s*>/.test(lines[i])) q.push(lines[i++].replace(/^\s*>\s?/, '')) | |
| 79 | + blocks.push(<blockquote key={blocks.length}><Markdown text={q.join('\n')} /></blockquote>) | |
| 80 | + continue | |
| 81 | + } | |
| 82 | + if (/^\s*\|.*\|\s*$/.test(line) && i + 1 < lines.length && /^\s*\|?\s*:?-{2,}/.test(lines[i + 1])) { | |
| 83 | + flushPara() | |
| 84 | + const cells = l => l.trim().replace(/^\||\|$/g, '').split('|').map(c => c.trim()) | |
| 85 | + const head = cells(line) | |
| 86 | + const aligns = cells(lines[i + 1]).map(c => (c.endsWith(':') && c.startsWith(':') ? 'center' : c.endsWith(':') ? 'right' : 'left')) | |
| 87 | + i += 2 | |
| 88 | + const rows = [] | |
| 89 | + while (i < lines.length && /^\s*\|.*\|\s*$/.test(lines[i])) rows.push(cells(lines[i++])) | |
| 90 | + blocks.push( | |
| 91 | + <div className="table-wrap" key={blocks.length}> | |
| 92 | + <table> | |
| 93 | + <thead><tr>{head.map((c, j) => <th key={j} style={{ textAlign: aligns[j] }}>{renderInline(c, `th${j}`)}</th>)}</tr></thead> | |
| 94 | + <tbody>{rows.map((r, ri) => <tr key={ri}>{r.map((c, j) => <td key={j} style={{ textAlign: aligns[j] }}>{renderInline(c, `td${ri}-${j}`)}</td>)}</tr>)}</tbody> | |
| 95 | + </table> | |
| 96 | + </div>, | |
| 97 | + ) | |
| 98 | + continue | |
| 99 | + } | |
| 100 | + const li = /^(\s*)([-*+]|\d+[.)])\s+(.*)$/.exec(line) | |
| 101 | + if (li) { | |
| 102 | + flushPara() | |
| 103 | + const ordered = /\d/.test(li[2]) | |
| 104 | + const items = [] | |
| 105 | + while (i < lines.length) { | |
| 106 | + const m = /^(\s*)([-*+]|\d+[.)])\s+(.*)$/.exec(lines[i]) | |
| 107 | + if (!m) break | |
| 108 | + const indent = m[1].length | |
| 109 | + if (indent > li[1].length && items.length) { | |
| 110 | + const sub = [] | |
| 111 | + while (i < lines.length) { | |
| 112 | + const s = /^(\s*)([-*+]|\d+[.)])\s+(.*)$/.exec(lines[i]) | |
| 113 | + if (!s || s[1].length <= li[1].length) break | |
| 114 | + sub.push(s[3]); i++ | |
| 115 | + } | |
| 116 | + items[items.length - 1].sub = sub | |
| 117 | + continue | |
| 118 | + } | |
| 119 | + items.push({ text: m[3] }); i++ | |
| 120 | + while (i < lines.length && /^\s{2,}\S/.test(lines[i]) && !/^\s*([-*+]|\d+[.)])\s/.test(lines[i])) items[items.length - 1].text += ' ' + lines[i++].trim() | |
| 121 | + } | |
| 122 | + const Tag = ordered ? 'ol' : 'ul' | |
| 123 | + blocks.push( | |
| 124 | + <Tag key={blocks.length}> | |
| 125 | + {items.map((it, j) => ( | |
| 126 | + <li key={j}>{renderInline(it.text, `li${j}`)}{it.sub && <ul>{it.sub.map((s, k) => <li key={k}>{renderInline(s, `sub${j}-${k}`)}</li>)}</ul>}</li> | |
| 127 | + ))} | |
| 128 | + </Tag>, | |
| 129 | + ) | |
| 130 | + continue | |
| 131 | + } | |
| 132 | + para.push(line.trim()); i++ | |
| 133 | + } | |
| 134 | + flushPara() | |
| 135 | + return <div className={`md ${className}`}>{blocks}</div> | |
| 136 | +} | |
added
hfmarketdata/web/src/components/SearchDialog.jsx
+98 −0
@@ -0,0 +1,98 @@ | ||
| 1 | +// <SearchDialog> — Cmd/Ctrl+K instant search over operations, guides and headings. Keyboard: ↑ ↓ Enter Esc. | |
| 2 | +// Index is built lazily from the (cached) OpenAPI spec + guides registry. Focus is trapped while open. | |
| 3 | +import React, { useEffect, useMemo, useRef, useState } from 'react' | |
| 4 | +import { useNavigate } from 'react-router-dom' | |
| 5 | +import { buildIndex, groupResults, search } from '../docs/search.js' | |
| 6 | +import { loadSpec } from '../docs/spec.js' | |
| 7 | +import { MethodBadge } from './Badge.jsx' | |
| 8 | +import { BookIcon, ChevronIcon, SearchIcon } from './Icons.jsx' | |
| 9 | + | |
| 10 | +let indexMemo = null | |
| 11 | +async function getIndex() { | |
| 12 | + if (indexMemo) return indexMemo | |
| 13 | + const { spec } = await loadSpec() | |
| 14 | + indexMemo = buildIndex(spec) | |
| 15 | + return indexMemo | |
| 16 | +} | |
| 17 | + | |
| 18 | +export default function SearchDialog({ open, onClose }) { | |
| 19 | + const [q, setQ] = useState('') | |
| 20 | + const [items, setItems] = useState(indexMemo || []) | |
| 21 | + const [sel, setSel] = useState(0) | |
| 22 | + const inputRef = useRef(null) | |
| 23 | + const listRef = useRef(null) | |
| 24 | + const navigate = useNavigate() | |
| 25 | + | |
| 26 | + useEffect(() => { | |
| 27 | + if (!open) return | |
| 28 | + setQ(''); setSel(0) | |
| 29 | + getIndex().then(setItems) | |
| 30 | + const t = setTimeout(() => inputRef.current?.focus(), 10) | |
| 31 | + const prev = document.body.style.overflow | |
| 32 | + document.body.style.overflow = 'hidden' | |
| 33 | + return () => { clearTimeout(t); document.body.style.overflow = prev } | |
| 34 | + }, [open]) | |
| 35 | + | |
| 36 | + const results = useMemo(() => search(items, q), [items, q]) | |
| 37 | + const groups = useMemo(() => groupResults(results), [results]) | |
| 38 | + useEffect(() => { setSel(0) }, [q]) | |
| 39 | + useEffect(() => { | |
| 40 | + const el = listRef.current?.querySelector('[aria-selected="true"]') | |
| 41 | + el?.scrollIntoView({ block: 'nearest' }) | |
| 42 | + }, [sel]) | |
| 43 | + | |
| 44 | + const go = item => { if (!item) return; onClose(); navigate(item.href) } | |
| 45 | + | |
| 46 | + const onKey = e => { | |
| 47 | + if (e.key === 'ArrowDown') { e.preventDefault(); setSel(s => Math.min(results.length - 1, s + 1)) } | |
| 48 | + else if (e.key === 'ArrowUp') { e.preventDefault(); setSel(s => Math.max(0, s - 1)) } | |
| 49 | + else if (e.key === 'Enter') { e.preventDefault(); go(results[sel]) } | |
| 50 | + else if (e.key === 'Escape') { e.preventDefault(); onClose() } | |
| 51 | + else if (e.key === 'Tab') { e.preventDefault() } // keep focus in the input | |
| 52 | + } | |
| 53 | + | |
| 54 | + if (!open) return null | |
| 55 | + let flat = -1 | |
| 56 | + return ( | |
| 57 | + <div className="search-backdrop" onMouseDown={e => { if (e.target === e.currentTarget) onClose() }}> | |
| 58 | + <div className="search-dialog" role="dialog" aria-modal="true" aria-label="Search documentation" data-testid="search-dialog"> | |
| 59 | + <div className="search-input-row"> | |
| 60 | + <SearchIcon /> | |
| 61 | + <input ref={inputRef} value={q} onChange={e => setQ(e.target.value)} onKeyDown={onKey} placeholder="Search endpoints, guides, error codes…" aria-label="Search" role="combobox" aria-expanded="true" aria-controls="search-results" aria-activedescendant={results[sel] ? `sr-${sel}` : undefined} autoComplete="off" spellCheck={false} /> | |
| 62 | + <kbd>esc</kbd> | |
| 63 | + </div> | |
| 64 | + <div className="search-results" id="search-results" role="listbox" ref={listRef}> | |
| 65 | + {results.length === 0 && <div className="search-empty">No results for “{q}”. Try an endpoint path, a ticker concept or an error code.</div>} | |
| 66 | + {groups.map(g => ( | |
| 67 | + <div key={g.name}> | |
| 68 | + <div className="search-group">{g.name}</div> | |
| 69 | + {g.items.map(it => { | |
| 70 | + flat += 1 | |
| 71 | + const idx = flat | |
| 72 | + return ( | |
| 73 | + <div key={it.href + it.title} id={`sr-${idx}`} role="option" aria-selected={idx === sel} className="search-item" onMouseEnter={() => setSel(idx)} onMouseDown={e => e.preventDefault()} onClick={() => go(it)}> | |
| 74 | + {it.method ? <MethodBadge method={it.method} /> : <span className="search-item-tag"><BookIcon /></span>} | |
| 75 | + <div className="search-item-text"> | |
| 76 | + <div className="search-item-title">{highlight(it.title, q)}</div> | |
| 77 | + {it.sub && <div className="search-item-sub">{it.sub}</div>} | |
| 78 | + </div> | |
| 79 | + <span className="search-item-tag"><ChevronIcon /></span> | |
| 80 | + </div> | |
| 81 | + ) | |
| 82 | + })} | |
| 83 | + </div> | |
| 84 | + ))} | |
| 85 | + </div> | |
| 86 | + <div className="search-foot"><span><kbd>↑</kbd> <kbd>↓</kbd> navigate</span><span><kbd>↵</kbd> open</span><span><kbd>esc</kbd> close</span></div> | |
| 87 | + </div> | |
| 88 | + </div> | |
| 89 | + ) | |
| 90 | +} | |
| 91 | + | |
| 92 | +function highlight(text, q) { | |
| 93 | + const t = q.trim().split(/\s+/)[0] | |
| 94 | + if (!t) return text | |
| 95 | + const i = text.toLowerCase().indexOf(t.toLowerCase()) | |
| 96 | + if (i < 0) return text | |
| 97 | + return <>{text.slice(0, i)}<mark>{text.slice(i, i + t.length)}</mark>{text.slice(i + t.length)}</> | |
| 98 | +} | |
added
hfmarketdata/web/src/components/Table.jsx
+23 −0
@@ -0,0 +1,23 @@ | ||
| 1 | +// <Table columns rows> — responsive data table. columns: [{ key, label, align?: 'right', render?(row) }]. | |
| 2 | +import React from 'react' | |
| 3 | + | |
| 4 | +export default function Table({ columns, rows, caption, keyField, dense = false, className = '', emptyText = 'No rows' }) { | |
| 5 | + return ( | |
| 6 | + <div className={`table-wrap ${dense ? 'dense' : ''} ${className}`}> | |
| 7 | + <table> | |
| 8 | + {caption && <caption className="sr-only">{caption}</caption>} | |
| 9 | + <thead> | |
| 10 | + <tr>{columns.map(c => <th key={c.key} scope="col" className={c.align === 'right' ? 'num' : ''} style={c.width ? { width: c.width } : undefined}>{c.label}</th>)}</tr> | |
| 11 | + </thead> | |
| 12 | + <tbody> | |
| 13 | + {rows.length === 0 && <tr><td colSpan={columns.length} className="muted">{emptyText}</td></tr>} | |
| 14 | + {rows.map((r, i) => ( | |
| 15 | + <tr key={keyField ? r[keyField] : i}> | |
| 16 | + {columns.map(c => <td key={c.key} className={c.align === 'right' ? 'num' : ''}>{c.render ? c.render(r) : r[c.key]}</td>)} | |
| 17 | + </tr> | |
| 18 | + ))} | |
| 19 | + </tbody> | |
| 20 | + </table> | |
| 21 | + </div> | |
| 22 | + ) | |
| 23 | +} | |
added
hfmarketdata/web/src/components/Tabs.jsx
+40 −0
@@ -0,0 +1,40 @@ | ||
| 1 | +// <Tabs> — accessible tab list (roving focus, arrow keys). Controlled or uncontrolled. | |
| 2 | +// items: [{ id, label, content?: ReactNode }] · value/onChange optional · renders children(activeId) when no content. | |
| 3 | +import React, { useId, useRef, useState } from 'react' | |
| 4 | + | |
| 5 | +export default function Tabs({ items, value, onChange, defaultValue, size = 'md', className = '', children, ariaLabel = 'Tabs' }) { | |
| 6 | + const [inner, setInner] = useState(defaultValue ?? items[0]?.id) | |
| 7 | + const active = value ?? inner | |
| 8 | + const set = id => { setInner(id); onChange?.(id) } | |
| 9 | + const base = useId() | |
| 10 | + const refs = useRef([]) | |
| 11 | + const onKey = (e, i) => { | |
| 12 | + const n = items.length | |
| 13 | + let j = null | |
| 14 | + if (e.key === 'ArrowRight') j = (i + 1) % n | |
| 15 | + else if (e.key === 'ArrowLeft') j = (i - 1 + n) % n | |
| 16 | + else if (e.key === 'Home') j = 0 | |
| 17 | + else if (e.key === 'End') j = n - 1 | |
| 18 | + if (j == null) return | |
| 19 | + e.preventDefault(); set(items[j].id); refs.current[j]?.focus() | |
| 20 | + } | |
| 21 | + const current = items.find(t => t.id === active) || items[0] | |
| 22 | + return ( | |
| 23 | + <div className={`tabs tabs-${size} ${className}`}> | |
| 24 | + <div role="tablist" aria-label={ariaLabel} className="tablist"> | |
| 25 | + {items.map((t, i) => ( | |
| 26 | + <button key={t.id} ref={el => (refs.current[i] = el)} role="tab" type="button" id={`${base}-tab-${t.id}`} | |
| 27 | + aria-selected={t.id === active} aria-controls={`${base}-panel-${t.id}`} tabIndex={t.id === active ? 0 : -1} | |
| 28 | + className={`tab ${t.id === active ? 'active' : ''}`} onClick={() => set(t.id)} onKeyDown={e => onKey(e, i)}> | |
| 29 | + {t.icon}{t.label} | |
| 30 | + </button> | |
| 31 | + ))} | |
| 32 | + </div> | |
| 33 | + {current && ( | |
| 34 | + <div role="tabpanel" id={`${base}-panel-${current.id}`} aria-labelledby={`${base}-tab-${current.id}`} className="tabpanel"> | |
| 35 | + {current.content ?? (typeof children === 'function' ? children(current.id) : children)} | |
| 36 | + </div> | |
| 37 | + )} | |
| 38 | + </div> | |
| 39 | + ) | |
| 40 | +} | |
added
hfmarketdata/web/src/components/components.css
+80 −0
@@ -0,0 +1,80 @@ | ||
| 1 | +/* Shared components: Code, Tabs, Callout, Badge, LangSelector, SearchDialog, Markdown prose. Owner: web-core. */ | |
| 2 | + | |
| 3 | +/* Code */ | |
| 4 | +.code { border: 1px solid var(--line); border-radius: var(--radius); background: var(--code-bg); overflow: hidden; margin: 0 0 16px; } | |
| 5 | +.code-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; padding: 6px 8px 6px 12px; border-bottom: 1px solid var(--line); background: color-mix(in srgb, var(--bg-1) 70%, var(--code-bg)); min-height: 34px; } | |
| 6 | +.code-title { font-family: var(--mono); font-size: 12px; color: var(--fg-2); letter-spacing: .02em; } | |
| 7 | +.code-pre { margin: 0; border: 0; border-radius: 0; background: transparent; padding: 12px 14px; font-size: 13px; line-height: 1.55; tab-size: 2; } | |
| 8 | +.code-line { display: block; white-space: pre; } | |
| 9 | +.code-ln { display: inline-block; width: 2.4em; color: var(--fg-3); user-select: none; text-align: right; padding-right: 1.2em; } | |
| 10 | +.copy-btn { display: inline-flex; align-items: center; gap: 5px; height: 24px; padding: 0 8px; border-radius: 6px; border: 1px solid transparent; background: transparent; color: var(--fg-2); cursor: pointer; font-size: 12px; } | |
| 11 | +.copy-btn:hover { background: var(--bg-2); color: var(--fg); border-color: var(--line-2); } | |
| 12 | +.copy-btn.copied { color: var(--accent); } | |
| 13 | +.copy-btn svg { width: 14px; height: 14px; } | |
| 14 | + | |
| 15 | +/* Tabs */ | |
| 16 | +.tablist { display: flex; gap: 2px; border-bottom: 1px solid var(--line); overflow-x: auto; scrollbar-width: none; } | |
| 17 | +.tablist::-webkit-scrollbar { display: none; } | |
| 18 | +.tab { position: relative; padding: 9px 12px; border: 0; background: transparent; color: var(--fg-2); cursor: pointer; font-weight: 500; font-size: 14px; white-space: nowrap; display: inline-flex; align-items: center; gap: 6px; } | |
| 19 | +.tab:hover { color: var(--fg); } | |
| 20 | +.tab.active { color: var(--fg); } | |
| 21 | +.tab.active::after { content: ""; position: absolute; left: 8px; right: 8px; bottom: -1px; height: 2px; background: var(--accent); border-radius: 2px; } | |
| 22 | +.tabs-sm .tab { padding: 6px 10px; font-size: 13px; } | |
| 23 | +.tabpanel { padding-top: 14px; } | |
| 24 | + | |
| 25 | +/* Language selector */ | |
| 26 | +.lang-selector { display: inline-flex; gap: 2px; padding: 2px; border-radius: 7px; background: var(--bg-2); border: 1px solid var(--line); } | |
| 27 | +.lang-btn { border: 0; background: transparent; color: var(--fg-2); font-size: 12.5px; padding: 3px 9px; border-radius: 5px; cursor: pointer; font-weight: 500; } | |
| 28 | +.lang-btn:hover { color: var(--fg); } | |
| 29 | +.lang-btn.active { background: var(--bg); color: var(--fg); box-shadow: 0 1px 2px rgba(0, 0, 0, .25); } | |
| 30 | +.codetabs { margin: 0 0 16px; } | |
| 31 | +.codetabs-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-bottom: 6px; } | |
| 32 | +.codetabs .code { margin-bottom: 0; } | |
| 33 | + | |
| 34 | +/* Callout */ | |
| 35 | +.callout { display: flex; gap: 12px; padding: 14px 16px; border-radius: var(--radius); border: 1px solid var(--line); background: var(--bg-1); margin: 0 0 18px; border-left-width: 3px; } | |
| 36 | +.callout-icon { flex: none; display: inline-grid; place-items: center; width: 22px; height: 22px; margin-top: 1px; } | |
| 37 | +.callout-icon svg { width: 18px; height: 18px; } | |
| 38 | +.callout-title { font-weight: 650; margin-bottom: 3px; } | |
| 39 | +.callout-body p:last-child { margin-bottom: 0; } | |
| 40 | +.callout-info { border-left-color: var(--accent-2); } .callout-info .callout-icon { color: var(--accent-2); } | |
| 41 | +.callout-tip { border-left-color: var(--accent); } .callout-tip .callout-icon { color: var(--accent); } | |
| 42 | +.callout-warning { border-left-color: var(--warn); background: var(--warn-soft); } .callout-warning .callout-icon { color: var(--warn); } | |
| 43 | +.callout-danger { border-left-color: var(--danger); background: var(--danger-soft); } .callout-danger .callout-icon { color: var(--danger); } | |
| 44 | + | |
| 45 | +/* Badges */ | |
| 46 | +.badge { display: inline-flex; align-items: center; gap: 4px; padding: 1px 8px; border-radius: 999px; font-size: 12px; font-weight: 600; border: 1px solid var(--line-2); color: var(--fg-1); background: var(--bg-2); line-height: 1.6; white-space: nowrap; } | |
| 47 | +.badge-success { color: var(--accent); border-color: color-mix(in srgb, var(--accent) 40%, transparent); background: var(--accent-soft); } | |
| 48 | +.badge-info { color: var(--accent-2); border-color: color-mix(in srgb, var(--accent-2) 40%, transparent); background: var(--accent-2-soft); } | |
| 49 | +.badge-warn { color: var(--warn); border-color: color-mix(in srgb, var(--warn) 40%, transparent); background: var(--warn-soft); } | |
| 50 | +.badge-danger { color: var(--danger); border-color: color-mix(in srgb, var(--danger) 40%, transparent); background: var(--danger-soft); } | |
| 51 | +.method { display: inline-block; font-family: var(--mono); font-size: 11px; font-weight: 700; letter-spacing: .04em; padding: 2px 7px; border-radius: 5px; color: #fff; line-height: 1.4; min-width: 44px; text-align: center; } | |
| 52 | +.method-get { background: var(--get); } .method-post { background: var(--post); color: var(--accent-ink); } .method-delete { background: var(--delete); } .method-put { background: var(--put); color: #1a1200; } .method-patch { background: var(--patch); } | |
| 53 | +[data-theme="light"] .method-post { color: #fff; } | |
| 54 | + | |
| 55 | +/* Search dialog */ | |
| 56 | +.search-backdrop { position: fixed; inset: 0; z-index: 80; background: rgba(0, 0, 0, .55); display: flex; justify-content: center; align-items: flex-start; padding: 12vh 16px 16px; } | |
| 57 | +.search-dialog { width: min(640px, 100%); background: var(--bg-1); border: 1px solid var(--line-2); border-radius: 12px; box-shadow: var(--shadow); overflow: hidden; } | |
| 58 | +.search-input-row { display: flex; align-items: center; gap: 10px; padding: 10px 14px; border-bottom: 1px solid var(--line); color: var(--fg-2); } | |
| 59 | +.search-input-row input { flex: 1; border: 0; background: transparent; padding: 6px 0; font-size: 16px; } | |
| 60 | +.search-input-row input:focus { outline: none; } | |
| 61 | +.search-results { max-height: 52vh; overflow-y: auto; padding: 6px; } | |
| 62 | +.search-group { font-size: 11px; text-transform: uppercase; letter-spacing: .08em; color: var(--fg-3); padding: 10px 10px 4px; font-weight: 650; } | |
| 63 | +.search-item { display: flex; align-items: center; gap: 10px; padding: 8px 10px; border-radius: 8px; color: var(--fg); cursor: pointer; } | |
| 64 | +.search-item[aria-selected="true"] { background: var(--bg-2); } | |
| 65 | +.search-item .method { flex: none; } | |
| 66 | +.search-item-text { min-width: 0; flex: 1; } | |
| 67 | +.search-item-title { font-weight: 550; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } | |
| 68 | +.search-item-sub { color: var(--fg-2); font-size: 12.5px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-family: var(--mono); } | |
| 69 | +.search-item-tag { color: var(--fg-3); font-size: 12px; flex: none; } | |
| 70 | +.search-empty { padding: 28px 12px; text-align: center; color: var(--fg-2); } | |
| 71 | +.search-foot { display: flex; gap: 14px; padding: 8px 14px; border-top: 1px solid var(--line); color: var(--fg-3); font-size: 12px; } | |
| 72 | +mark { background: var(--accent-soft); color: inherit; border-radius: 2px; } | |
| 73 | + | |
| 74 | +/* Markdown prose */ | |
| 75 | +.md > :first-child { margin-top: 0; } | |
| 76 | +.md p, .md ul, .md ol { margin: 0 0 12px; } | |
| 77 | +.md li + li { margin-top: 4px; } | |
| 78 | +.md blockquote { margin: 0 0 14px; padding: 8px 14px; border-left: 3px solid var(--line-3); color: var(--fg-1); background: var(--bg-1); border-radius: 0 6px 6px 0; } | |
| 79 | +.md .table-wrap { margin-bottom: 16px; } | |
| 80 | +.md h1, .md h2, .md h3, .md h4 { margin-top: 1.6em; } | |
added
hfmarketdata/web/src/components/index.js
+10 −0
@@ -0,0 +1,10 @@ | ||
| 1 | +// Shared component barrel — see hfmarketdata/web/README.md for the API of each component. | |
| 2 | +export { default as Code, CopyButton, useCopy } from './Code.jsx' | |
| 3 | +export { default as CodeTabs } from './CodeTabs.jsx' | |
| 4 | +export { default as Tabs } from './Tabs.jsx' | |
| 5 | +export { default as Callout } from './Callout.jsx' | |
| 6 | +export { default as Table } from './Table.jsx' | |
| 7 | +export { default as Badge, MethodBadge, StatusBadge } from './Badge.jsx' | |
| 8 | +export { default as LangSelector, LangProvider, useLang, LANGS } from './LangSelector.jsx' | |
| 9 | +export { default as Markdown, InlineMd } from './Markdown.jsx' | |
| 10 | +export * from './Icons.jsx' | |
added
hfmarketdata/web/src/docs/enrich.js
+94 −0
@@ -0,0 +1,94 @@ | ||
| 1 | +// Realistic example values for any operation, derived from the spec (defaults, enums, examples) plus a | |
| 2 | +// name-based dictionary so v2 endpoints get sensible values before the backend ships `example`s. | |
| 3 | +// Never an API key here — public examples use env variables ($HFMD_API_KEY). | |
| 4 | +import { PUBLIC_BASE } from '../app/api.js' | |
| 5 | + | |
| 6 | +// Per-parameter-name defaults (checked after the spec's own example/default when the param is required). | |
| 7 | +export const NAME_DEFAULTS = { | |
| 8 | + asset: 'stock', ticker: 'AAPL', tickers: 'AAPL,MSFT,NVDA', root: 'ES', symbol: 'ESZ25', concept: 'Assets', | |
| 9 | + timeframe: '1day', interval: '1d', adjustment: '', start: '2024-06-03', end: '2024-06-07', from: '2024-01-01', to: '2024-06-30', | |
| 10 | + as_of: '2024-06-03', at: '2024-06-03 10:35:00', trade_date: '2024-06-21', expiry: '2024-12-20', strike: '200', call_put: 'c', | |
| 11 | + quarter: '2024_q4', year: '2024', roll: 'volume', adjust: 'back_adjusted', depth: '1', session: 'all', format: 'json', | |
| 12 | + limit: '5', order: 'asc', search: 'AAP', period: 'quarterly', fiscal_year: '2024', fiscal_period: 'Q2', statement: 'balance_sheet', | |
| 13 | + cik: '320193', form: '10-Q', frequency: 'quarterly', unit: 'USD', range: '24h', min_volume: '', strike_min: '', strike_max: '', | |
| 14 | + cursor: '', api_key: '', email: 'you@example.com', name: 'default', | |
| 15 | +} | |
| 16 | + | |
| 17 | +// Endpoint-specific overrides keyed by "METHOD /path" (path as written in the spec). | |
| 18 | +export const PATH_OVERRIDES = { | |
| 19 | + 'GET /v1/bars/{asset}/{ticker}': { asset: 'stock', ticker: 'AAPL', timeframe: '1day', start: '2024-06-03', limit: '5' }, | |
| 20 | + 'GET /v1/bars/{asset}': { asset: 'stock', tickers: 'AAPL,MSFT,NVDA', timeframe: '1min', start: '2024-06-03 09:30:00', end: '2024-06-03 09:35:00', limit: '3' }, | |
| 21 | + 'GET /v1/snapshot/{asset}': { asset: 'stock', tickers: 'AAPL,MSFT,NVDA', at: '2024-06-03 10:35:00', timeframe: '1min' }, | |
| 22 | + 'GET /v1/{asset}/tickers': { asset: 'stock', timeframe: '1day', search: 'AAP', limit: '10' }, | |
| 23 | + 'GET /v1/options/chain/{ticker}': { ticker: 'AAPL', trade_date: '2024-06-21', call_put: 'c', strike_min: '200', strike_max: '210', limit: '5' }, | |
| 24 | + 'GET /v1/options/expirations/{ticker}': { ticker: 'AAPL', trade_date: '2024-06-21' }, | |
| 25 | + 'GET /v1/options/history/{ticker}': { ticker: 'AAPL', strike: '200', expiry: '2024-12-20', call_put: 'c', limit: '5' }, | |
| 26 | + 'GET /v1/options/tickers': { search: 'TSL', limit: '10' }, | |
| 27 | + 'GET /v1/futures/{root}/contracts': { root: 'ES' }, | |
| 28 | + 'GET /v1/futures/contract/{symbol}/bars': { symbol: 'ESZ25', interval: '1d', from: '2025-06-01', to: '2025-06-30', limit: '5' }, | |
| 29 | + 'GET /v1/futures/contract/{symbol}/coverage': { symbol: 'ESZ25' }, | |
| 30 | + 'GET /v1/futures/{root}/chain': { root: 'CL', as_of: '2024-06-03' }, | |
| 31 | + 'GET /v1/futures/{root}/continuous': { root: 'ES', roll: 'volume', adjust: 'back_adjusted', depth: '1', interval: '1d', from: '2024-01-01', to: '2024-03-31', limit: '5' }, | |
| 32 | + 'GET /v1/futures/{root}/term-structure': { root: 'CL', as_of: '2024-06-03' }, | |
| 33 | + 'GET /v1/fundamentals/{ticker}/statements': { ticker: 'AAPL', statement: 'balance_sheet', period: 'quarterly', from: '2024-01-01', to: '2024-06-30' }, | |
| 34 | + 'GET /v1/fundamentals/{ticker}/facts/{concept}': { ticker: 'AAPL', concept: 'Assets', from: '2023-01-01' }, | |
| 35 | + 'GET /v1/fundamentals/{ticker}/ratios': { ticker: 'AAPL', period: 'quarterly', from: '2024-01-01' }, | |
| 36 | + 'GET /v1/fundamentals/{ticker}/ratios/daily': { ticker: 'AAPL', from: '2024-01-01', to: '2024-06-30', limit: '5' }, | |
| 37 | + 'GET /v1/fundamentals/{ticker}/filings': { ticker: 'AAPL', form: '10-Q', from: '2024-01-01' }, | |
| 38 | + 'GET /v1/fundamentals/{ticker}/coverage': { ticker: 'AAPL' }, | |
| 39 | + 'GET /v1/fundamentals/screener': { limit: '10' }, | |
| 40 | + 'GET /v1/fundamentals/frames/{concept}': { concept: 'Assets', fiscal_year: '2024', fiscal_period: 'Q2' }, | |
| 41 | + 'GET /v1/bulk/fundamentals/{year}.parquet': { year: '2024' }, | |
| 42 | +} | |
| 43 | + | |
| 44 | +// Params we always show when present (even if optional) so examples look like real calls. | |
| 45 | +const SHOW_OPTIONAL = new Set(['timeframe', 'interval', 'start', 'end', 'from', 'to', 'limit', 'as_of', 'roll', 'adjust', 'trade_date', 'search', 'period', 'statement', 'form', 'fiscal_year', 'fiscal_period', 'call_put', 'strike_min', 'strike_max', 'at', 'tickers', 'depth']) | |
| 46 | + | |
| 47 | +export function exampleParams(op) { | |
| 48 | + const over = PATH_OVERRIDES[`${op.method} ${op.path}`] || {} | |
| 49 | + const values = {} | |
| 50 | + for (const p of op.parameters) { | |
| 51 | + if (p.in === 'header' || p.in === 'cookie') continue | |
| 52 | + if (p.name === 'api_key') continue | |
| 53 | + let v = over[p.name] | |
| 54 | + if (v === undefined && p.example !== undefined) v = p.example | |
| 55 | + if (v === undefined && (p.required || SHOW_OPTIONAL.has(p.name))) { | |
| 56 | + if (p.enum && p.enum.length && !(p.name in NAME_DEFAULTS)) v = p.default ?? p.enum[0] | |
| 57 | + else v = NAME_DEFAULTS[p.name] ?? p.default ?? (p.enum ? p.enum[0] : p.type === 'integer' || p.type === 'number' ? 1 : `<${p.name}>`) | |
| 58 | + } | |
| 59 | + if (v === undefined || v === '' || v === null) continue | |
| 60 | + values[p.name] = String(v) | |
| 61 | + } | |
| 62 | + return values | |
| 63 | +} | |
| 64 | + | |
| 65 | +/** Build the request URL from an operation and a values map. Path params are substituted, the rest become query. */ | |
| 66 | +export function buildRequest(op, values, base = PUBLIC_BASE) { | |
| 67 | + let path = op.path | |
| 68 | + const query = [] | |
| 69 | + const pathParams = new Set(op.parameters.filter(p => p.in === 'path').map(p => p.name)) | |
| 70 | + for (const name of pathParams) path = path.replace(`{${name}}`, encodeURIComponent(values[name] ?? `{${name}}`)) | |
| 71 | + for (const [k, v] of Object.entries(values)) { | |
| 72 | + if (pathParams.has(k) || v === undefined || v === '' || v === null) continue | |
| 73 | + query.push(`${encodeURIComponent(k)}=${encodeURIComponent(v).replace(/%2C/g, ',').replace(/%3A/g, ':').replace(/%20/g, '%20')}`) | |
| 74 | + } | |
| 75 | + const rel = path + (query.length ? `?${query.join('&')}` : '') | |
| 76 | + return { path, query, rel, url: base.replace(/\/$/, '') + rel, format: values.format || 'json' } | |
| 77 | +} | |
| 78 | + | |
| 79 | +/** Playground deep link: /playground?ep=<operationId>&<param>=<value>… (contract documented in README). */ | |
| 80 | +export function playgroundLink(op, values) { | |
| 81 | + const q = new URLSearchParams({ ep: op.id }) | |
| 82 | + for (const [k, v] of Object.entries(values || {})) if (v !== '' && v != null) q.set(k, v) | |
| 83 | + return `/playground?${q.toString()}` | |
| 84 | +} | |
| 85 | + | |
| 86 | +// Lazy per-operation example files captured at build time by scripts/fetch-examples.mjs. | |
| 87 | +const exampleFiles = import.meta.glob('../../content/examples/*.json') | |
| 88 | +export async function loadExampleFile(opId) { | |
| 89 | + const key = `../../content/examples/${opId}.json` | |
| 90 | + if (!exampleFiles[key]) return null | |
| 91 | + const mod = await exampleFiles[key]() | |
| 92 | + return mod.default || mod | |
| 93 | +} | |
| 94 | +export const hasExampleFile = opId => !!exampleFiles[`../../content/examples/${opId}.json`] | |
added
hfmarketdata/web/src/docs/guides.js
+67 −0
@@ -0,0 +1,67 @@ | ||
| 1 | +// Guides registry — the docs navigation tree. Each guide is an MDX file in content/guides (lazy chunk). | |
| 2 | +// To add a guide: drop the .mdx file, add one entry here (slug, title, section, file). Headings are indexed | |
| 3 | +// automatically for search by the `virtual:guides-index` Vite plugin (see vite.config.js). | |
| 4 | +import guidesIndex from 'virtual:guides-index' | |
| 5 | + | |
| 6 | +const mdx = import.meta.glob('../../content/guides/*.mdx') | |
| 7 | +const changelog = () => import('../../content/changelog.mdx') | |
| 8 | + | |
| 9 | +export const SECTIONS = [ | |
| 10 | + { id: 'start', title: 'Getting started' }, | |
| 11 | + { id: 'guides', title: 'Guides' }, | |
| 12 | + { id: 'recipes', title: 'Recipes' }, | |
| 13 | + { id: 'fundamentals', title: 'Fundamentals' }, | |
| 14 | + { id: 'reference', title: 'Reference' }, | |
| 15 | + { id: 'more', title: 'More' }, | |
| 16 | +] | |
| 17 | + | |
| 18 | +export const GUIDES = [ | |
| 19 | + { slug: 'quickstart', title: 'Quickstart', section: 'start', file: 'quickstart', summary: 'Your first request in 30 seconds — no signup.' }, | |
| 20 | + { slug: 'authentication', title: 'Authentication & API keys', section: 'start', file: 'authentication', summary: 'Keyless, free account, high usage — how keys work.' }, | |
| 21 | + { slug: 'rate-limits', title: 'Rate limits', section: 'start', file: 'rate-limits', summary: 'Tiers, headers, handling 429, best practices.' }, | |
| 22 | + { slug: 'data-formats', title: 'Data formats', section: 'start', file: 'data-formats', summary: 'JSON vs CSV vs Parquet, with a size benchmark.' }, | |
| 23 | + { slug: 'futures-contracts', title: 'Futures: individual contracts', section: 'guides', file: 'futures-contracts', summary: 'Month codes, ESZ25 vs continuous, roll & adjustment methods.' }, | |
| 24 | + { slug: 'options', title: 'Options chains & Greeks', section: 'guides', file: 'options', summary: 'End-of-day chains, IV, Greeks, contract histories.' }, | |
| 25 | + { slug: 'time-zones', title: 'Time zones & sessions', section: 'guides', file: 'time-zones', summary: 'RTH / ETH, US/Eastern intraday stamps, UTC output in v2.' }, | |
| 26 | + { slug: 'bulk-downloads', title: 'Bulk downloads', section: 'guides', file: 'bulk-downloads', summary: 'Whole-universe Parquet extracts outside the rows quota.' }, | |
| 27 | + { slug: 'recipes/pandas-backtest', title: 'Backtest a strategy in pandas', section: 'recipes', file: 'recipe-pandas-backtest', summary: 'Moving-average crossover on ES continuous, end to end.' }, | |
| 28 | + { slug: 'recipes/custom-continuous', title: 'Build a custom continuous contract', section: 'recipes', file: 'recipe-custom-continuous', summary: 'Stitch individual contracts with your own roll rule.' }, | |
| 29 | + { slug: 'recipes/crude-term-structure', title: 'Analyse crude oil term structure', section: 'recipes', file: 'recipe-crude-term-structure', summary: 'Contango / backwardation from the CL curve.' }, | |
| 30 | + { slug: 'fundamentals/balance-sheet', title: 'Reading a balance sheet through the API', section: 'fundamentals', file: 'fundamentals-balance-sheet', summary: 'Apple Q2 FY2024, line by line.' }, | |
| 31 | + { slug: 'fundamentals/fiscal-vs-calendar', title: 'Fiscal vs calendar periods', section: 'fundamentals', file: 'fundamentals-fiscal-vs-calendar', summary: 'Why Apple\'s Q2 ends in March.' }, | |
| 32 | + { slug: 'fundamentals/point-in-time', title: 'Point-in-time & look-ahead bias', section: 'fundamentals', file: 'fundamentals-point-in-time', summary: 'Backtests that only know what was public.' }, | |
| 33 | + { slug: 'fundamentals/ratios', title: 'All ratio formulas', section: 'fundamentals', file: 'fundamentals-ratios', summary: 'Every ratio the API computes, with its exact formula.' }, | |
| 34 | + { slug: 'fundamentals/screener', title: 'Screener', section: 'fundamentals', file: 'fundamentals-screener', summary: 'Filter the universe by fundamentals.' }, | |
| 35 | + { slug: 'fundamentals/stream', title: 'WebSocket filings stream', section: 'fundamentals', file: 'fundamentals-stream', summary: 'Live SEC filings over WebSocket.' }, | |
| 36 | + { slug: 'changelog', title: 'Changelog', section: 'more', file: '__changelog', summary: 'Dated release notes.' }, | |
| 37 | + { slug: 'versioning', title: 'Versioning & deprecation policy', section: 'more', file: 'versioning', summary: 'What can change, and how we tell you.' }, | |
| 38 | +] | |
| 39 | + | |
| 40 | +// Non-MDX entries rendered by dedicated components (kept here so nav + search see them). | |
| 41 | +export const STATIC_PAGES = [ | |
| 42 | + { slug: 'errors', title: 'Errors', section: 'more', summary: 'Every error code, with meaning and fix.' }, | |
| 43 | +] | |
| 44 | + | |
| 45 | +export const EXTERNAL_LINKS = [ | |
| 46 | + { href: '/pricing', title: 'Limits & pricing', section: 'more' }, | |
| 47 | + { href: '/integrations', title: 'Integrations (MCP, skills)', section: 'more' }, | |
| 48 | + { href: '/status', title: 'Status', section: 'more' }, | |
| 49 | +] | |
| 50 | + | |
| 51 | +export function guideBySlug(slug) { | |
| 52 | + return GUIDES.find(g => g.slug === slug) || null | |
| 53 | +} | |
| 54 | + | |
| 55 | +export function loadGuide(guide) { | |
| 56 | + if (guide.file === '__changelog') return changelog() | |
| 57 | + const loader = mdx[`../../content/guides/${guide.file}.mdx`] | |
| 58 | + if (!loader) return Promise.reject(new Error(`Missing guide file: ${guide.file}.mdx`)) | |
| 59 | + return loader() | |
| 60 | +} | |
| 61 | + | |
| 62 | +/** Headings extracted at build time: { [file]: [{ depth, text, id }] } */ | |
| 63 | +export function guideHeadings(guide) { | |
| 64 | + return guidesIndex[guide.file === '__changelog' ? 'changelog' : guide.file] || [] | |
| 65 | +} | |
| 66 | + | |
| 67 | +export const guideHref = g => `/docs/${g.slug}` | |
added
hfmarketdata/web/src/docs/mdx-components.jsx
+71 −0
@@ -0,0 +1,71 @@ | ||
| 1 | +// Components available inside every MDX guide (via MDXProvider) — no imports needed in the .mdx files. | |
| 2 | +// Headings get stable ids (same slug rule as the build-time index → search anchors always resolve). | |
| 3 | +import React from 'react' | |
| 4 | +import { Link } from 'react-router-dom' | |
| 5 | +import Badge, { MethodBadge } from '../components/Badge.jsx' | |
| 6 | +import Callout from '../components/Callout.jsx' | |
| 7 | +import Code from '../components/Code.jsx' | |
| 8 | +import CodeTabs from '../components/CodeTabs.jsx' | |
| 9 | +import Table from '../components/Table.jsx' | |
| 10 | +import Tabs from '../components/Tabs.jsx' | |
| 11 | +import { MdLink } from '../components/Markdown.jsx' | |
| 12 | +import { PUBLIC_BASE } from '../app/api.js' | |
| 13 | + | |
| 14 | +export const slugify = s => s.toLowerCase().replace(/[`*_]/g, '').replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '') | |
| 15 | + | |
| 16 | +function textOf(node) { | |
| 17 | + if (node == null || typeof node === 'boolean') return '' | |
| 18 | + if (typeof node === 'string' || typeof node === 'number') return String(node) | |
| 19 | + if (Array.isArray(node)) return node.map(textOf).join('') | |
| 20 | + if (node.props?.children) return textOf(node.props.children) | |
| 21 | + return '' | |
| 22 | +} | |
| 23 | + | |
| 24 | +const Heading = level => function H({ children, id, ...rest }) { | |
| 25 | + const Tag = `h${level}` | |
| 26 | + const slug = id || slugify(textOf(children)) | |
| 27 | + return ( | |
| 28 | + <Tag id={slug} className="anchor-heading" {...rest}> | |
| 29 | + <a href={`#${slug}`} className="anchor" aria-label="Link to this section">#</a>{children} | |
| 30 | + </Tag> | |
| 31 | + ) | |
| 32 | +} | |
| 33 | + | |
| 34 | +function Pre({ children }) { | |
| 35 | + // MDX: <pre><code className="language-python">…</code></pre> | |
| 36 | + const child = React.Children.only(children) | |
| 37 | + const lang = (child.props.className || '').replace('language-', '') || 'text' | |
| 38 | + const meta = child.props.metastring || child.props.title | |
| 39 | + return <Code code={textOf(child.props.children)} language={lang} title={meta || null} /> | |
| 40 | +} | |
| 41 | + | |
| 42 | +/** <Endpoint method="GET" path="/v1/bars/{asset}/{ticker}" id="operationId?"> — links to the reference page. */ | |
| 43 | +export function Endpoint({ method = 'GET', path, id, children }) { | |
| 44 | + const href = id ? `/docs/reference/${id}` : `/docs/reference?find=${encodeURIComponent(`${method} ${path}`)}` | |
| 45 | + return ( | |
| 46 | + <Link to={href} className="endpoint-chip"> | |
| 47 | + <MethodBadge method={method} /><code>{path}</code>{children} | |
| 48 | + </Link> | |
| 49 | + ) | |
| 50 | +} | |
| 51 | + | |
| 52 | +/** <TryIt ep="operationId" params={{ ticker: 'AAPL' }}>Try it</TryIt> — deep link to the playground. */ | |
| 53 | +export function TryIt({ ep, params = {}, children = 'Try it in the playground' }) { | |
| 54 | + const q = new URLSearchParams({ ep, ...params }) | |
| 55 | + return <Link to={`/playground?${q}`} className="btn btn-sm btn-primary">{children}</Link> | |
| 56 | +} | |
| 57 | + | |
| 58 | +/** <Url path="/v1/…"> renders the absolute production URL as code. */ | |
| 59 | +export const Url = ({ path }) => <code>{PUBLIC_BASE}{path}</code> | |
| 60 | + | |
| 61 | +export function Figure({ children, caption }) { | |
| 62 | + return <figure className="figure">{children}{caption && <figcaption>{caption}</figcaption>}</figure> | |
| 63 | +} | |
| 64 | + | |
| 65 | +export const mdxComponents = { | |
| 66 | + h1: Heading(1), h2: Heading(2), h3: Heading(3), h4: Heading(4), | |
| 67 | + pre: Pre, | |
| 68 | + a: ({ href, children }) => <MdLink href={href}>{children}</MdLink>, | |
| 69 | + table: ({ children }) => <div className="table-wrap"><table>{children}</table></div>, | |
| 70 | + Callout, CodeTabs, Tabs, Table, Badge, MethodBadge, Code, Endpoint, TryIt, Url, Figure, Link, | |
| 71 | +} | |
added
hfmarketdata/web/src/docs/search.js
+61 −0
@@ -0,0 +1,61 @@ | ||
| 1 | +// Instant search index over operations, guides and headings. Pure functions; the dialog owns the UI. | |
| 2 | +import { EXTERNAL_LINKS, GUIDES, SECTIONS, STATIC_PAGES, guideHeadings, guideHref } from './guides.js' | |
| 3 | +import { listOperations } from './spec.js' | |
| 4 | + | |
| 5 | +export function buildIndex(spec) { | |
| 6 | + const items = [] | |
| 7 | + const sectionTitle = id => SECTIONS.find(s => s.id === id)?.title || '' | |
| 8 | + for (const g of GUIDES) { | |
| 9 | + items.push({ type: 'guide', title: g.title, sub: g.summary, href: guideHref(g), group: sectionTitle(g.section), keywords: g.slug }) | |
| 10 | + for (const h of guideHeadings(g)) { | |
| 11 | + items.push({ type: 'heading', title: h.text, sub: g.title, href: `${guideHref(g)}#${h.id}`, group: sectionTitle(g.section), keywords: '' }) | |
| 12 | + } | |
| 13 | + } | |
| 14 | + for (const p of STATIC_PAGES) items.push({ type: 'guide', title: p.title, sub: p.summary, href: `/docs/${p.slug}`, group: sectionTitle(p.section), keywords: p.slug }) | |
| 15 | + for (const l of EXTERNAL_LINKS) items.push({ type: 'page', title: l.title, sub: l.href, href: l.href, group: sectionTitle(l.section), keywords: '' }) | |
| 16 | + if (spec) { | |
| 17 | + for (const op of listOperations(spec)) { | |
| 18 | + items.push({ type: 'operation', title: op.summary, sub: `${op.method} ${op.path}`, href: `/docs/reference/${op.id}`, group: `Reference · ${op.tag}`, method: op.method, keywords: `${op.tag} ${op.id} ${op.description.slice(0, 200)}` }) | |
| 19 | + } | |
| 20 | + const codes = spec?.components?.schemas?.Error?.properties?.error?.properties?.code?.enum || [] | |
| 21 | + for (const c of codes) items.push({ type: 'heading', title: c, sub: 'Error code', href: `/docs/errors#${c.toLowerCase()}`, group: 'Errors', keywords: 'error' }) | |
| 22 | + } | |
| 23 | + return items | |
| 24 | +} | |
| 25 | + | |
| 26 | +const norm = s => (s || '').toLowerCase() | |
| 27 | + | |
| 28 | +export function search(items, query, limit = 24) { | |
| 29 | + const q = norm(query).trim() | |
| 30 | + if (!q) return items.filter(i => i.type !== 'heading').slice(0, 12) | |
| 31 | + const terms = q.split(/\s+/).filter(Boolean) | |
| 32 | + const scored = [] | |
| 33 | + for (const it of items) { | |
| 34 | + const title = norm(it.title), sub = norm(it.sub), kw = norm(it.keywords) | |
| 35 | + let score = 0 | |
| 36 | + for (const t of terms) { | |
| 37 | + if (title === t) score += 40 | |
| 38 | + else if (title.startsWith(t)) score += 22 | |
| 39 | + else if (title.includes(t)) score += 14 | |
| 40 | + else if (sub.includes(t)) score += 9 | |
| 41 | + else if (kw.includes(t)) score += 4 | |
| 42 | + else { score = -1; break } | |
| 43 | + } | |
| 44 | + if (score < 0) continue | |
| 45 | + if (it.type === 'operation') score += 3 | |
| 46 | + if (it.type === 'heading') score -= 2 | |
| 47 | + scored.push({ it, score }) | |
| 48 | + } | |
| 49 | + scored.sort((a, b) => b.score - a.score || a.it.title.length - b.it.title.length) | |
| 50 | + return scored.slice(0, limit).map(s => s.it) | |
| 51 | +} | |
| 52 | + | |
| 53 | +export function groupResults(results) { | |
| 54 | + const groups = [] | |
| 55 | + for (const r of results) { | |
| 56 | + let g = groups.find(x => x.name === r.group) | |
| 57 | + if (!g) { g = { name: r.group, items: [] }; groups.push(g) } | |
| 58 | + g.items.push(r) | |
| 59 | + } | |
| 60 | + return groups | |
| 61 | +} | |
added
hfmarketdata/web/src/docs/snippets.js
+85 −0
@@ -0,0 +1,85 @@ | ||
| 1 | +// Code sample generation — curl / Python (requests + pandas) / JavaScript (fetch) / R (httr2) from a request. | |
| 2 | +// Keys are never inlined: samples read HFMD_API_KEY from the environment when `auth` is requested. | |
| 3 | +import { LANGS } from '../components/LangSelector.jsx' | |
| 4 | + | |
| 5 | +const shellQuote = s => `"${s.replace(/"/g, '\\"')}"` | |
| 6 | + | |
| 7 | +/** | |
| 8 | + * @param {object} req { method, url, format, body?, auth? } | |
| 9 | + * @param {string} req.format json | csv | parquet — steers the pandas/R parsing line | |
| 10 | + * @param {boolean} [req.auth] add the Authorization header (env var) | |
| 11 | + * @param {boolean} [req.stream] WebSocket endpoint | |
| 12 | + */ | |
| 13 | +export function snippetsFor({ method = 'GET', url, format = 'json', body, auth = false, stream = false }) { | |
| 14 | + if (stream) return wsSnippets(url) | |
| 15 | + const m = method.toUpperCase() | |
| 16 | + const bodyJson = body ? JSON.stringify(body, null, 2) : null | |
| 17 | + return { curl: curl(m, url, bodyJson, auth), python: python(m, url, format, bodyJson, auth), javascript: javascript(m, url, format, bodyJson, auth), r: rlang(m, url, format, bodyJson, auth) } | |
| 18 | +} | |
| 19 | + | |
| 20 | +function curl(m, url, bodyJson, auth) { | |
| 21 | + const lines = [`curl ${m !== 'GET' ? `-X ${m} ` : ''}${shellQuote(url)}`] | |
| 22 | + if (auth) lines.push(` -H "Authorization: Bearer $HFMD_API_KEY"`) | |
| 23 | + if (bodyJson) { lines.push(` -H "Content-Type: application/json"`); lines.push(` -d '${bodyJson.replace(/\n\s*/g, ' ')}'`) } | |
| 24 | + return lines.join(' \\\n') | |
| 25 | +} | |
| 26 | + | |
| 27 | +function python(m, url, format, bodyJson, auth) { | |
| 28 | + const imp = ['import os', 'import requests', 'import pandas as pd'] | |
| 29 | + if (format === 'parquet') imp.push('from io import BytesIO') | |
| 30 | + if (format === 'csv') imp.push('from io import StringIO') | |
| 31 | + const hdr = auth ? `headers = {"Authorization": f"Bearer {os.environ['HFMD_API_KEY']}"}\n` : '' | |
| 32 | + const call = m === 'GET' | |
| 33 | + ? `r = requests.get(\n "${url}",\n${auth ? ' headers=headers,\n' : ''} timeout=60,\n)` | |
| 34 | + : `r = requests.request(\n "${m}", "${url}",\n${auth ? ' headers=headers,\n' : ''}${bodyJson ? ` json=${pyLiteral(bodyJson)},\n` : ''} timeout=60,\n)` | |
| 35 | + let parse | |
| 36 | + if (format === 'csv') parse = `df = pd.read_csv(StringIO(r.text))` | |
| 37 | + else if (format === 'parquet') parse = `df = pd.read_parquet(BytesIO(r.content)) # Parquet counts half against the rows quota` | |
| 38 | + else parse = `payload = r.json()\ndf = pd.DataFrame(payload["data"]) if isinstance(payload, dict) and "data" in payload else pd.json_normalize(payload)` | |
| 39 | + return `${imp.join('\n')}\n\n${hdr}${call}\nr.raise_for_status()\nprint(r.headers.get("X-RateLimit-Remaining-Requests"), "requests left")\n${parse}\nprint(df.head())` | |
| 40 | +} | |
| 41 | + | |
| 42 | +function pyLiteral(json) { return json.replace(/\btrue\b/g, 'True').replace(/\bfalse\b/g, 'False').replace(/\bnull\b/g, 'None') } | |
| 43 | + | |
| 44 | +function javascript(m, url, format, bodyJson, auth) { | |
| 45 | + const opts = [] | |
| 46 | + if (m !== 'GET') opts.push(`method: "${m}"`) | |
| 47 | + const headers = [] | |
| 48 | + if (auth) headers.push(`Authorization: \`Bearer \${process.env.HFMD_API_KEY}\``) | |
| 49 | + if (bodyJson) headers.push(`"Content-Type": "application/json"`) | |
| 50 | + if (headers.length) opts.push(`headers: { ${headers.join(', ')} }`) | |
| 51 | + if (bodyJson) opts.push(`body: JSON.stringify(${bodyJson.replace(/\n\s*/g, ' ')})`) | |
| 52 | + const optStr = opts.length ? `, {\n ${opts.join(',\n ')},\n}` : '' | |
| 53 | + let parse | |
| 54 | + if (format === 'csv') parse = `const csv = await res.text();\nconsole.log(csv.split("\\n").slice(0, 3).join("\\n"));` | |
| 55 | + else if (format === 'parquet') parse = `const bytes = new Uint8Array(await res.arrayBuffer()); // feed to parquet-wasm / duckdb-wasm\nconsole.log(bytes.byteLength, "bytes");` | |
| 56 | + else parse = `const payload = await res.json();\nconsole.table((payload.data ?? payload).slice?.(0, 5) ?? payload);` | |
| 57 | + return `const res = await fetch("${url}"${optStr});\nif (!res.ok) {\n const { error } = await res.json();\n throw new Error(\`\${error.code}: \${error.message} (\${error.docs})\`);\n}\nconsole.log(res.headers.get("X-RateLimit-Remaining-Requests"), "requests left");\n${parse}` | |
| 58 | +} | |
| 59 | + | |
| 60 | +function rlang(m, url, format, bodyJson, auth) { | |
| 61 | + const lines = ['library(httr2)'] | |
| 62 | + if (format === 'parquet') lines.push('library(arrow)') | |
| 63 | + lines.push('', `req <- request("${url}")`) | |
| 64 | + if (m !== 'GET') lines.push(`req <- req |> req_method("${m}")`) | |
| 65 | + if (auth) lines.push(`req <- req |> req_auth_bearer_token(Sys.getenv("HFMD_API_KEY"))`) | |
| 66 | + if (bodyJson) lines.push(`req <- req |> req_body_json(jsonlite::fromJSON('${bodyJson.replace(/\n\s*/g, ' ')}'))`) | |
| 67 | + lines.push(`resp <- req_perform(req)`, `cat(resp_header(resp, "X-RateLimit-Remaining-Requests"), "requests left\\n")`) | |
| 68 | + if (format === 'csv') lines.push(`df <- read.csv(text = resp_body_string(resp))`) | |
| 69 | + else if (format === 'parquet') lines.push(`df <- as.data.frame(arrow::read_parquet(resp_body_raw(resp)))`) | |
| 70 | + else lines.push(`payload <- resp_body_json(resp, simplifyVector = TRUE)`, `df <- if (is.list(payload) && !is.null(payload$data)) as.data.frame(payload$data) else payload`) | |
| 71 | + lines.push(`head(df)`) | |
| 72 | + return lines.join('\n') | |
| 73 | +} | |
| 74 | + | |
| 75 | +function wsSnippets(url) { | |
| 76 | + const ws = url.replace(/^http/, 'ws') | |
| 77 | + return { | |
| 78 | + curl: `# websocat: https://github.com/vi/websocat\nwebsocat "${ws}"`, | |
| 79 | + python: `import asyncio, json, os\nimport websockets # pip install websockets\n\nasync def main():\n headers = {"Authorization": f"Bearer {os.environ.get('HFMD_API_KEY', '')}"}\n async with websockets.connect("${ws}", additional_headers=headers) as ws:\n async for msg in ws:\n event = json.loads(msg)\n print(event.get("type"), event.get("data"))\n\nasyncio.run(main())`, | |
| 80 | + javascript: `const ws = new WebSocket("${ws}");\nws.onmessage = (ev) => {\n const event = JSON.parse(ev.data);\n console.log(event.type, event.data);\n};\nws.onclose = (ev) => console.log("closed", ev.code, ev.reason);`, | |
| 81 | + r: `# R has no mainstream WebSocket client for long-lived streams; the 'websocket' package works:\nlibrary(websocket)\nws <- WebSocket$new("${ws}")\nws$onMessage(function(event) print(jsonlite::fromJSON(event$data)))`, | |
| 82 | + } | |
| 83 | +} | |
| 84 | + | |
| 85 | +export const langMeta = id => LANGS.find(l => l.id === id) || LANGS[0] | |
added
hfmarketdata/web/src/docs/spec.js
+199 −0
@@ -0,0 +1,199 @@ | ||
| 1 | +// OpenAPI loader + helpers. Fetches /openapi.json once per session (ETag revalidation, localStorage cache), | |
| 2 | +// falls back to the bundled snapshot (content/openapi.snapshot.json) when the API is unreachable (e.g. `vite preview`). | |
| 3 | +import { useEffect, useState } from 'react' | |
| 4 | +import { BASE_URL } from '../app/api.js' | |
| 5 | + | |
| 6 | +const LS_KEY = 'hfmd.openapi.v1' | |
| 7 | +let memo = null // { spec, source } | |
| 8 | +let inflight = null | |
| 9 | + | |
| 10 | +function readCache() { | |
| 11 | + try { const raw = localStorage.getItem(LS_KEY); return raw ? JSON.parse(raw) : null } catch { return null } | |
| 12 | +} | |
| 13 | +function writeCache(etag, spec) { | |
| 14 | + try { localStorage.setItem(LS_KEY, JSON.stringify({ etag, spec, at: Date.now() })) } catch { /* quota */ } | |
| 15 | +} | |
| 16 | + | |
| 17 | +async function snapshot() { | |
| 18 | + const mod = await import('../../content/openapi.snapshot.json') | |
| 19 | + return mod.default || mod | |
| 20 | +} | |
| 21 | + | |
| 22 | +export async function loadSpec({ force = false } = {}) { | |
| 23 | + if (memo && !force) return memo | |
| 24 | + if (inflight) return inflight | |
| 25 | + inflight = (async () => { | |
| 26 | + const cached = readCache() | |
| 27 | + try { | |
| 28 | + const headers = { Accept: 'application/json' } | |
| 29 | + if (cached?.etag) headers['If-None-Match'] = cached.etag | |
| 30 | + const res = await fetch(`${BASE_URL}/openapi.json`, { headers }) | |
| 31 | + if (res.status === 304 && cached?.spec) { memo = { spec: cached.spec, source: 'cache' }; return memo } | |
| 32 | + const ct = res.headers.get('content-type') || '' | |
| 33 | + if (res.ok && ct.includes('json')) { | |
| 34 | + const spec = await res.json() | |
| 35 | + if (spec && spec.paths) { writeCache(res.headers.get('etag'), spec); memo = { spec, source: 'live' }; return memo } | |
| 36 | + } | |
| 37 | + throw new Error(`openapi.json unavailable (${res.status})`) | |
| 38 | + } catch (e) { | |
| 39 | + if (cached?.spec) { memo = { spec: cached.spec, source: 'cache' }; return memo } | |
| 40 | + memo = { spec: await snapshot(), source: 'snapshot' } | |
| 41 | + return memo | |
| 42 | + } finally { | |
| 43 | + inflight = null | |
| 44 | + } | |
| 45 | + })() | |
| 46 | + return inflight | |
| 47 | +} | |
| 48 | + | |
| 49 | +export function useSpec() { | |
| 50 | + const [state, setState] = useState(() => (memo ? { ...memo, loading: false, error: null } : { spec: null, source: null, loading: true, error: null })) | |
| 51 | + useEffect(() => { | |
| 52 | + if (memo) return | |
| 53 | + let alive = true | |
| 54 | + loadSpec().then(r => alive && setState({ ...r, loading: false, error: null }), e => alive && setState({ spec: null, source: null, loading: false, error: e })) | |
| 55 | + return () => { alive = false } | |
| 56 | + }, []) | |
| 57 | + return state | |
| 58 | +} | |
| 59 | + | |
| 60 | +// ---- helpers ------------------------------------------------------------------------------------- | |
| 61 | + | |
| 62 | +export function resolveRef(spec, obj, depth = 0) { | |
| 63 | + if (!obj || typeof obj !== 'object' || depth > 12) return obj | |
| 64 | + if (obj.$ref && typeof obj.$ref === 'string' && obj.$ref.startsWith('#/')) { | |
| 65 | + const target = obj.$ref.slice(2).split('/').reduce((o, k) => (o == null ? undefined : o[decodeURIComponent(k).replace(/~1/g, '/').replace(/~0/g, '~')]), spec) | |
| 66 | + return resolveRef(spec, target, depth + 1) | |
| 67 | + } | |
| 68 | + return obj | |
| 69 | +} | |
| 70 | + | |
| 71 | +export const METHODS = ['get', 'post', 'put', 'patch', 'delete'] | |
| 72 | + | |
| 73 | +/** Flatten every operation of the spec. */ | |
| 74 | +export function listOperations(spec) { | |
| 75 | + if (!spec?.paths) return [] | |
| 76 | + const ops = [] | |
| 77 | + for (const [path, item] of Object.entries(spec.paths)) { | |
| 78 | + const shared = item.parameters || [] | |
| 79 | + for (const method of METHODS) { | |
| 80 | + const op = item[method] | |
| 81 | + if (!op) continue | |
| 82 | + const id = op.operationId || `${method}_${path.replace(/[^a-z0-9]+/gi, '_')}` | |
| 83 | + ops.push({ | |
| 84 | + id, method: method.toUpperCase(), path, | |
| 85 | + tag: (op.tags && op.tags[0]) || 'other', | |
| 86 | + summary: op.summary || humanize(id), | |
| 87 | + description: op.description || '', | |
| 88 | + deprecated: !!op.deprecated, | |
| 89 | + parameters: [...shared, ...(op.parameters || [])].map(p => paramInfo(spec, resolveRef(spec, p))), | |
| 90 | + requestBody: op.requestBody ? resolveRef(spec, op.requestBody) : null, | |
| 91 | + responses: op.responses || {}, | |
| 92 | + security: op.security, | |
| 93 | + raw: op, | |
| 94 | + }) | |
| 95 | + } | |
| 96 | + } | |
| 97 | + return ops | |
| 98 | +} | |
| 99 | + | |
| 100 | +function humanize(id) { | |
| 101 | + return id.replace(/_v1_.*$/, '').replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase()) | |
| 102 | +} | |
| 103 | + | |
| 104 | +export function schemaType(schema) { | |
| 105 | + if (!schema) return 'string' | |
| 106 | + if (schema.type) return Array.isArray(schema.type) ? schema.type.filter(t => t !== 'null').join(' | ') : schema.type | |
| 107 | + if (schema.anyOf || schema.oneOf) { | |
| 108 | + const types = (schema.anyOf || schema.oneOf).map(s => schemaType(s)).filter(t => t !== 'null') | |
| 109 | + return [...new Set(types)].join(' | ') || 'string' | |
| 110 | + } | |
| 111 | + if (schema.enum) return typeof schema.enum[0] | |
| 112 | + return 'string' | |
| 113 | +} | |
| 114 | + | |
| 115 | +function pick(schema, key) { | |
| 116 | + if (!schema) return undefined | |
| 117 | + if (schema[key] !== undefined) return schema[key] | |
| 118 | + for (const s of schema.anyOf || schema.oneOf || []) if (s && s[key] !== undefined) return s[key] | |
| 119 | + return undefined | |
| 120 | +} | |
| 121 | + | |
| 122 | +export function paramInfo(spec, p) { | |
| 123 | + const schema = resolveRef(spec, p.schema) || {} | |
| 124 | + const constraints = [] | |
| 125 | + const min = pick(schema, 'minimum'), max = pick(schema, 'maximum'), pattern = pick(schema, 'pattern') | |
| 126 | + const minLen = pick(schema, 'minLength'), maxLen = pick(schema, 'maxLength'), format = pick(schema, 'format') | |
| 127 | + if (min !== undefined) constraints.push(`≥ ${min}`) | |
| 128 | + if (max !== undefined) constraints.push(`≤ ${max}`) | |
| 129 | + if (minLen !== undefined) constraints.push(`min length ${minLen}`) | |
| 130 | + if (maxLen !== undefined) constraints.push(`max length ${maxLen}`) | |
| 131 | + if (pattern) constraints.push(`pattern ${pattern}`) | |
| 132 | + if (format) constraints.push(format) | |
| 133 | + const en = pick(schema, 'enum') || enumFromPattern(pattern) | |
| 134 | + return { | |
| 135 | + name: p.name, in: p.in || 'query', required: !!p.required, | |
| 136 | + type: schemaType(schema), default: pick(schema, 'default'), enum: en || null, | |
| 137 | + constraints, description: p.description || schema.description || '', | |
| 138 | + example: p.example ?? pick(schema, 'example') ?? (p.examples ? Object.values(p.examples)[0]?.value : undefined), | |
| 139 | + deprecated: !!p.deprecated, | |
| 140 | + } | |
| 141 | +} | |
| 142 | + | |
| 143 | +// FastAPI often expresses small enums as `^(a|b|c)$` patterns — surface them as choices. | |
| 144 | +function enumFromPattern(pattern) { | |
| 145 | + const m = pattern && /^\^\(([\w|.-]+)\)\$$/.exec(pattern) | |
| 146 | + return m ? m[1].split('|') : null | |
| 147 | +} | |
| 148 | + | |
| 149 | +/** Ordered tag list with descriptions and their operations. Unknown tags appended alphabetically. */ | |
| 150 | +export function groupByTag(spec, ops) { | |
| 151 | + const declared = spec?.tags || [] | |
| 152 | + const byName = new Map() | |
| 153 | + for (const t of declared) byName.set(t.name, { name: t.name, description: t.description || '', ops: [] }) | |
| 154 | + for (const op of ops) { | |
| 155 | + if (!byName.has(op.tag)) byName.set(op.tag, { name: op.tag, description: '', ops: [] }) | |
| 156 | + byName.get(op.tag).ops.push(op) | |
| 157 | + } | |
| 158 | + return [...byName.values()].filter(t => t.ops.length) | |
| 159 | +} | |
| 160 | + | |
| 161 | +/** First JSON example found on the 2xx response of an operation (spec-declared), else null. */ | |
| 162 | +export function responseExample(spec, op) { | |
| 163 | + for (const [code, r0] of Object.entries(op.responses || {})) { | |
| 164 | + if (!code.startsWith('2')) continue | |
| 165 | + const r = resolveRef(spec, r0) | |
| 166 | + const content = r?.content || {} | |
| 167 | + const media = content['application/json'] || Object.values(content)[0] | |
| 168 | + if (!media) continue | |
| 169 | + if (media.example !== undefined) return { code, value: media.example, mediaType: mediaTypeOf(content, media) } | |
| 170 | + if (media.examples) { const first = resolveRef(spec, Object.values(media.examples)[0]); if (first?.value !== undefined) return { code, value: first.value, mediaType: mediaTypeOf(content, media) } } | |
| 171 | + const schema = resolveRef(spec, media.schema) | |
| 172 | + if (schema?.example !== undefined) return { code, value: schema.example, mediaType: mediaTypeOf(content, media) } | |
| 173 | + if (schema?.examples?.length) return { code, value: schema.examples[0], mediaType: mediaTypeOf(content, media) } | |
| 174 | + } | |
| 175 | + return null | |
| 176 | +} | |
| 177 | +function mediaTypeOf(content, media) { return Object.keys(content).find(k => content[k] === media) || 'application/json' } | |
| 178 | + | |
| 179 | +/** Error responses declared on the operation: [{ status, codes: [...], description }]. */ | |
| 180 | +export function errorResponses(spec, op) { | |
| 181 | + const out = [] | |
| 182 | + for (const [status, r0] of Object.entries(op.responses || {})) { | |
| 183 | + if (!/^[45]/.test(status)) continue | |
| 184 | + const r = resolveRef(spec, r0) | |
| 185 | + const desc = r?.description || '' | |
| 186 | + const codes = [...desc.matchAll(/`([A-Z_]{4,})`/g)].map(m => m[1]) | |
| 187 | + out.push({ status, codes: [...new Set(codes)], description: desc }) | |
| 188 | + } | |
| 189 | + return out.sort((a, b) => Number(a.status) - Number(b.status)) | |
| 190 | +} | |
| 191 | + | |
| 192 | +export function errorCodes(spec) { | |
| 193 | + const en = spec?.components?.schemas?.Error?.properties?.error?.properties?.code?.enum | |
| 194 | + return Array.isArray(en) ? en : [] | |
| 195 | +} | |
| 196 | + | |
| 197 | +export function tiers(spec) { return spec?.info?.['x-tiers'] || null } | |
| 198 | + | |
| 199 | +export const specVersion = spec => spec?.info?.version || '' | |
added
hfmarketdata/web/src/docs/useTitle.js
+16 −0
@@ -0,0 +1,16 @@ | ||
| 1 | +// Document title + meta description for client-side routes (prerendered shells carry their own tags). | |
| 2 | +import { useEffect } from 'react' | |
| 3 | + | |
| 4 | +export default function useTitle(title, description) { | |
| 5 | + useEffect(() => { | |
| 6 | + const prev = document.title | |
| 7 | + document.title = title ? `${title} · HF Market Data` : 'HF Market Data — Open High-Frequency Market Data API' | |
| 8 | + let meta = document.querySelector('meta[name="description"]') | |
| 9 | + const prevDesc = meta?.getAttribute('content') | |
| 10 | + if (description) { | |
| 11 | + if (!meta) { meta = document.createElement('meta'); meta.name = 'description'; document.head.appendChild(meta) } | |
| 12 | + meta.setAttribute('content', description) | |
| 13 | + } | |
| 14 | + return () => { document.title = prev; if (description && meta && prevDesc != null) meta.setAttribute('content', prevDesc) } | |
| 15 | + }, [title, description]) | |
| 16 | +} | |
modified
hfmarketdata/web/src/main.jsx
+5 −1
@@ -3,13 +3,17 @@ import { createRoot } from 'react-dom/client' | ||
| 3 | 3 | import { BrowserRouter } from 'react-router-dom' |
| 4 | 4 | import App from './App.jsx' |
| 5 | 5 | import { AuthProvider } from './app/auth.jsx' |
| 6 | +import { LangProvider } from './components/LangSelector.jsx' | |
| 6 | 7 | import './app/theme.css' |
| 8 | +import './components/components.css' | |
| 7 | 9 | |
| 8 | 10 | createRoot(document.getElementById('root')).render( |
| 9 | 11 | <React.StrictMode> |
| 10 | 12 | <BrowserRouter> |
| 11 | 13 | <AuthProvider> |
| 12 | − <App /> | |
| 14 | + <LangProvider> | |
| 15 | + <App /> | |
| 16 | + </LangProvider> | |
| 13 | 17 | </AuthProvider> |
| 14 | 18 | </BrowserRouter> |
| 15 | 19 | </React.StrictMode>, |
modified
hfmarketdata/web/src/pages/docs/Docs.jsx
+27 −2
@@ -1,5 +1,30 @@ | ||
| 1 | +// /docs/* router. Reference pages are generated from /openapi.json; guides are MDX (content/guides). | |
| 1 | 2 | import React from 'react' |
| 2 | −// Placeholder — implemented by the owning agent (see App.jsx header). Keep the default export name. | |
| 3 | +import { Route, Routes, useLocation } from 'react-router-dom' | |
| 4 | +import DocsHome from './DocsHome.jsx' | |
| 5 | +import DocsLayout from './DocsLayout.jsx' | |
| 6 | +import ErrorsPage from './ErrorsPage.jsx' | |
| 7 | +import GuidePage from './GuidePage.jsx' | |
| 8 | +import OperationPage from './OperationPage.jsx' | |
| 9 | +import ReferenceIndex from './ReferenceIndex.jsx' | |
| 10 | + | |
| 11 | +function GuideRoute() { | |
| 12 | + const { pathname } = useLocation() | |
| 13 | + const slug = pathname.replace(/^\/docs\/?/, '').replace(/\/$/, '') | |
| 14 | + return <GuidePage slug={slug} /> | |
| 15 | +} | |
| 16 | + | |
| 3 | 17 | export default function Docs() { |
| 4 | − return <main className="page"><h1>Docs</h1><p className="muted">Coming soon.</p></main> | |
| 18 | + return ( | |
| 19 | + <DocsLayout> | |
| 20 | + <Routes> | |
| 21 | + <Route index element={<DocsHome />} /> | |
| 22 | + <Route path="reference" element={<ReferenceIndex />} /> | |
| 23 | + <Route path="reference/tag/:tag" element={<ReferenceIndex />} /> | |
| 24 | + <Route path="reference/:opId" element={<OperationPage />} /> | |
| 25 | + <Route path="errors" element={<ErrorsPage />} /> | |
| 26 | + <Route path="*" element={<GuideRoute />} /> | |
| 27 | + </Routes> | |
| 28 | + </DocsLayout> | |
| 29 | + ) | |
| 5 | 30 | } |
added
hfmarketdata/web/src/pages/docs/DocsHome.jsx
+55 −0
@@ -0,0 +1,55 @@ | ||
| 1 | +// /docs landing: quick start snippet, section cards, reference tags. | |
| 2 | +import React, { useMemo } from 'react' | |
| 3 | +import { Link } from 'react-router-dom' | |
| 4 | +import { PUBLIC_BASE } from '../../app/api.js' | |
| 5 | +import CodeTabs from '../../components/CodeTabs.jsx' | |
| 6 | +import { GUIDES, SECTIONS, guideHref } from '../../docs/guides.js' | |
| 7 | +import { snippetsFor } from '../../docs/snippets.js' | |
| 8 | +import { groupByTag, listOperations, useSpec } from '../../docs/spec.js' | |
| 9 | +import useTitle from '../../docs/useTitle.js' | |
| 10 | +import { DocPage, OnThisPage } from './DocsLayout.jsx' | |
| 11 | + | |
| 12 | +const FIRST = `${PUBLIC_BASE}/v1/bars/stock/AAPL?timeframe=1min&start=2024-06-03%2009:30:00&end=2024-06-03%2010:00:00` | |
| 13 | + | |
| 14 | +export default function DocsHome() { | |
| 15 | + useTitle('Documentation', 'HF Market Data documentation: quickstart, authentication, rate limits, futures contracts, options, fundamentals, and the full API reference.') | |
| 16 | + const { spec } = useSpec() | |
| 17 | + const tags = useMemo(() => (spec ? groupByTag(spec, listOperations(spec)) : []), [spec]) | |
| 18 | + const snippets = useMemo(() => snippetsFor({ url: FIRST }), []) | |
| 19 | + const headings = SECTIONS.filter(s => s.id !== 'more').map(s => ({ depth: 2, text: s.title, id: s.id })) | |
| 20 | + const panel = ( | |
| 21 | + <> | |
| 22 | + <div className="panel-block"><CodeTabs title="Your first request — no signup" snippets={snippets} maxHeight={420} /></div> | |
| 23 | + <OnThisPage headings={headings} /> | |
| 24 | + </> | |
| 25 | + ) | |
| 26 | + return ( | |
| 27 | + <DocPage panel={panel} testId="docs-home"> | |
| 28 | + <header> | |
| 29 | + <h1>Documentation</h1> | |
| 30 | + <p className="lead">Historical market data from 1-minute to daily since 2010 — stocks, ETFs, futures (continuous and individual contracts), crypto, indices, FX — plus options chains with Greeks and SEC EDGAR fundamentals. One base URL, JSON / CSV / Parquet, no signup to start.</p> | |
| 31 | + <p><code>{PUBLIC_BASE}/v1</code></p> | |
| 32 | + </header> | |
| 33 | + {SECTIONS.filter(s => s.id !== 'more' && s.id !== 'reference').map(s => ( | |
| 34 | + <section key={s.id}> | |
| 35 | + <h2 id={s.id}>{s.title}</h2> | |
| 36 | + <div className="docs-home-grid"> | |
| 37 | + {GUIDES.filter(g => g.section === s.id).map(g => ( | |
| 38 | + <Link key={g.slug} to={guideHref(g)} className="docs-home-card"><h3>{g.title}</h3><p>{g.summary}</p></Link> | |
| 39 | + ))} | |
| 40 | + </div> | |
| 41 | + </section> | |
| 42 | + ))} | |
| 43 | + <section> | |
| 44 | + <h2 id="reference">Reference</h2> | |
| 45 | + <p className="muted">Generated from the live OpenAPI 3.1 specification. Every page has request samples in four languages, a real response and a <em>Try it</em> button.</p> | |
| 46 | + <div className="docs-home-grid"> | |
| 47 | + {tags.map(t => ( | |
| 48 | + <Link key={t.name} to={`/docs/reference/tag/${t.name}`} className="docs-home-card"><h3 style={{ textTransform: 'capitalize' }}>{t.name} <span className="muted small">· {t.ops.length}</span></h3><p>{t.description}</p></Link> | |
| 49 | + ))} | |
| 50 | + <Link to="/docs/errors" className="docs-home-card"><h3>Errors</h3><p>Every error code, meaning and fix.</p></Link> | |
| 51 | + </div> | |
| 52 | + </section> | |
| 53 | + </DocPage> | |
| 54 | + ) | |
| 55 | +} | |
added
hfmarketdata/web/src/pages/docs/DocsLayout.jsx
+148 −0
@@ -0,0 +1,148 @@ | ||
| 1 | +// Docs shell: left nav (guides + reference generated from the spec), DocPage (content + sticky code panel), | |
| 2 | +// OnThisPage outline, footer with Edit / Report links. DOM order = visual order on every breakpoint. | |
| 3 | +import React, { useEffect, useMemo, useState } from 'react' | |
| 4 | +import { Link, NavLink, useLocation } from 'react-router-dom' | |
| 5 | +import { CONTACT_EMAIL, PUBLIC_BASE } from '../../app/api.js' | |
| 6 | +import { REPO_URL } from '../../app/Layout.jsx' | |
| 7 | +import { MethodBadge } from '../../components/Badge.jsx' | |
| 8 | +import { ChevronIcon, EditIcon, GitIcon, MenuIcon, SearchIcon } from '../../components/Icons.jsx' | |
| 9 | +import { EXTERNAL_LINKS, GUIDES, SECTIONS, STATIC_PAGES, guideHref } from '../../docs/guides.js' | |
| 10 | +import { groupByTag, listOperations, useSpec } from '../../docs/spec.js' | |
| 11 | +import './docs.css' | |
| 12 | + | |
| 13 | +const isMac = typeof navigator !== 'undefined' && /Mac|iPhone|iPad/.test(navigator.platform || navigator.userAgent) | |
| 14 | + | |
| 15 | +export default function DocsLayout({ children }) { | |
| 16 | + const [open, setOpen] = useState(false) | |
| 17 | + const location = useLocation() | |
| 18 | + useEffect(() => { setOpen(false) }, [location.pathname]) | |
| 19 | + return ( | |
| 20 | + <div className="docs"> | |
| 21 | + <div className="docs-nav-toggle"> | |
| 22 | + <button type="button" className="btn btn-sm" onClick={() => setOpen(o => !o)} aria-expanded={open} aria-controls="docs-nav"><MenuIcon /> Docs menu</button> | |
| 23 | + <span className="muted">{currentLabel(location.pathname)}</span> | |
| 24 | + </div> | |
| 25 | + <DocsNav open={open} /> | |
| 26 | + <div className="docs-main">{children}</div> | |
| 27 | + </div> | |
| 28 | + ) | |
| 29 | +} | |
| 30 | + | |
| 31 | +function currentLabel(path) { | |
| 32 | + const g = GUIDES.find(g => guideHref(g) === path) | |
| 33 | + if (g) return g.title | |
| 34 | + if (path.startsWith('/docs/reference')) return 'Reference' | |
| 35 | + if (path === '/docs/errors') return 'Errors' | |
| 36 | + return 'Overview' | |
| 37 | +} | |
| 38 | + | |
| 39 | +function openSearch() { window.dispatchEvent(new KeyboardEvent('keydown', { key: 'k', metaKey: true, ctrlKey: true, bubbles: true })) } | |
| 40 | + | |
| 41 | +export function DocsNav({ open }) { | |
| 42 | + const { spec } = useSpec() | |
| 43 | + const location = useLocation() | |
| 44 | + const tags = useMemo(() => (spec ? groupByTag(spec, listOperations(spec)) : []), [spec]) | |
| 45 | + const activeTag = tags.find(t => t.ops.some(op => location.pathname === `/docs/reference/${op.id}`) || location.pathname === `/docs/reference/tag/${t.name}`)?.name | |
| 46 | + return ( | |
| 47 | + <nav className={`docs-nav ${open ? 'open' : ''}`} id="docs-nav" aria-label="Documentation"> | |
| 48 | + <button type="button" className="docs-nav-search" onClick={openSearch}><SearchIcon /> Search<kbd>{isMac ? '⌘' : 'Ctrl'} K</kbd></button> | |
| 49 | + <div className="docs-nav-section"> | |
| 50 | + <NavLink to="/docs" end>Overview</NavLink> | |
| 51 | + </div> | |
| 52 | + {SECTIONS.filter(s => s.id !== 'reference' && s.id !== 'more').map(s => ( | |
| 53 | + <div className="docs-nav-section" key={s.id}> | |
| 54 | + <div className="docs-nav-title">{s.title}</div> | |
| 55 | + {GUIDES.filter(g => g.section === s.id).map(g => <NavLink key={g.slug} to={guideHref(g)}>{g.title}</NavLink>)} | |
| 56 | + </div> | |
| 57 | + ))} | |
| 58 | + <div className="docs-nav-section"> | |
| 59 | + <div className="docs-nav-title">Reference</div> | |
| 60 | + <NavLink to="/docs/reference" end>All endpoints</NavLink> | |
| 61 | + {tags.map(t => ( | |
| 62 | + <details key={t.name} open={activeTag === t.name || undefined}> | |
| 63 | + <summary>{t.name.charAt(0).toUpperCase() + t.name.slice(1)} <ChevronIcon /></summary> | |
| 64 | + <div> | |
| 65 | + {t.ops.map(op => ( | |
| 66 | + <NavLink key={op.id} to={`/docs/reference/${op.id}`} className="nav-op" title={`${op.method} ${op.path}`}><MethodBadge method={op.method} />{op.summary}</NavLink> | |
| 67 | + ))} | |
| 68 | + </div> | |
| 69 | + </details> | |
| 70 | + ))} | |
| 71 | + <NavLink to="/docs/errors">Errors</NavLink> | |
| 72 | + </div> | |
| 73 | + <div className="docs-nav-section"> | |
| 74 | + <div className="docs-nav-title">More</div> | |
| 75 | + {GUIDES.filter(g => g.section === 'more').map(g => <NavLink key={g.slug} to={guideHref(g)}>{g.title}</NavLink>)} | |
| 76 | + {STATIC_PAGES.filter(p => p.slug !== 'errors').map(p => <NavLink key={p.slug} to={`/docs/${p.slug}`}>{p.title}</NavLink>)} | |
| 77 | + {EXTERNAL_LINKS.map(l => <NavLink key={l.href} to={l.href}>{l.title}</NavLink>)} | |
| 78 | + <a href={`${PUBLIC_BASE}/openapi.json`}>openapi.json ↗</a> | |
| 79 | + </div> | |
| 80 | + </nav> | |
| 81 | + ) | |
| 82 | +} | |
| 83 | + | |
| 84 | +/** Centre column + sticky right panel. `panel` null → single wide column. */ | |
| 85 | +export function DocPage({ children, panel, crumbs, footer = true, testId }) { | |
| 86 | + const location = useLocation() | |
| 87 | + return ( | |
| 88 | + <div className={`docpage ${panel ? '' : 'no-panel'}`} data-testid={testId}> | |
| 89 | + <article className="docs-content"> | |
| 90 | + {crumbs && ( | |
| 91 | + <div className="docs-crumbs" aria-label="Breadcrumb"> | |
| 92 | + <Link to="/docs">Docs</Link> | |
| 93 | + {crumbs.map((c, i) => <React.Fragment key={i}><ChevronIcon />{c.href ? <Link to={c.href}>{c.label}</Link> : <span>{c.label}</span>}</React.Fragment>)} | |
| 94 | + </div> | |
| 95 | + )} | |
| 96 | + {children} | |
| 97 | + {footer && <DocsFooter path={location.pathname} />} | |
| 98 | + </article> | |
| 99 | + {panel && <aside className="docs-panel" aria-label="Code and outline"><div className="docs-panel-inner">{panel}</div></aside>} | |
| 100 | + </div> | |
| 101 | + ) | |
| 102 | +} | |
| 103 | + | |
| 104 | +export function DocsFooter({ path }) { | |
| 105 | + const url = `${PUBLIC_BASE}${path}` | |
| 106 | + const mail = `mailto:${CONTACT_EMAIL}?subject=${encodeURIComponent(`[hfmarketdata docs] Issue on ${path}`)}&body=${encodeURIComponent(`Page: ${url}\n\nWhat is wrong / missing:\n`)}` | |
| 107 | + return ( | |
| 108 | + <div className="docs-footer"> | |
| 109 | + <a href={mail}><EditIcon /> Edit / Report an issue</a> | |
| 110 | + <a href={REPO_URL} rel="noopener noreferrer"><GitIcon /> Source repository</a> | |
| 111 | + </div> | |
| 112 | + ) | |
| 113 | +} | |
| 114 | + | |
| 115 | +/** "On this page" outline; tracks the heading currently in view. headings: [{ depth, text, id }] */ | |
| 116 | +export function OnThisPage({ headings }) { | |
| 117 | + const [active, setActive] = useState(null) | |
| 118 | + useEffect(() => { | |
| 119 | + if (!headings?.length || typeof IntersectionObserver === 'undefined') return | |
| 120 | + const els = headings.map(h => document.getElementById(h.id)).filter(Boolean) | |
| 121 | + if (!els.length) return | |
| 122 | + const io = new IntersectionObserver(entries => { | |
| 123 | + const visible = entries.filter(e => e.isIntersecting).sort((a, b) => a.boundingClientRect.top - b.boundingClientRect.top) | |
| 124 | + if (visible[0]) setActive(visible[0].target.id) | |
| 125 | + }, { rootMargin: '-64px 0px -70% 0px', threshold: [0, 1] }) | |
| 126 | + els.forEach(el => io.observe(el)) | |
| 127 | + return () => io.disconnect() | |
| 128 | + }, [headings]) | |
| 129 | + if (!headings?.length) return null | |
| 130 | + return ( | |
| 131 | + <div className="panel-block"> | |
| 132 | + <h4>On this page</h4> | |
| 133 | + <nav className="toc" aria-label="On this page"> | |
| 134 | + {headings.map(h => <a key={h.id} href={`#${h.id}`} className={`depth-${h.depth} ${active === h.id ? 'active' : ''}`}>{h.text}</a>)} | |
| 135 | + </nav> | |
| 136 | + </div> | |
| 137 | + ) | |
| 138 | +} | |
| 139 | + | |
| 140 | +export function PrevNext({ prev, next }) { | |
| 141 | + if (!prev && !next) return null | |
| 142 | + return ( | |
| 143 | + <nav className="docs-prevnext" aria-label="Previous / next"> | |
| 144 | + <div>{prev && <Link to={prev.href}><span className="lbl">← Previous</span>{prev.title}</Link>}</div> | |
| 145 | + <div>{next && <Link to={next.href} className="next"><span className="lbl">Next →</span>{next.title}</Link>}</div> | |
| 146 | + </nav> | |
| 147 | + ) | |
| 148 | +} | |
added
hfmarketdata/web/src/pages/docs/ErrorsPage.jsx
+89 −0
@@ -0,0 +1,89 @@ | ||
| 1 | +// Every error code from components.schemas.Error.properties.code.enum, with anchors #<code lower> | |
| 2 | +// (the API's error.docs links point here). Meanings/fixes are curated; unknown codes still get an entry. | |
| 3 | +import React, { useEffect, useMemo } from 'react' | |
| 4 | +import { Link, useLocation } from 'react-router-dom' | |
| 5 | +import { StatusBadge } from '../../components/Badge.jsx' | |
| 6 | +import Code from '../../components/Code.jsx' | |
| 7 | +import Callout from '../../components/Callout.jsx' | |
| 8 | +import { errorCodes, useSpec } from '../../docs/spec.js' | |
| 9 | +import useTitle from '../../docs/useTitle.js' | |
| 10 | +import { DocPage, OnThisPage } from './DocsLayout.jsx' | |
| 11 | + | |
| 12 | +export const ERROR_INFO = { | |
| 13 | + INVALID_PARAMETER: { status: 400, meaning: 'A query or path parameter is malformed or out of range.', fix: 'Read `error.message` — it names the parameter and lists valid values (e.g. unknown `timeframe`, `adjustment` not available for this asset). Fix the value and retry; no need to back off.' }, | |
| 14 | + VALIDATION_ERROR: { status: 422, meaning: 'The request did not match the endpoint schema (wrong type, missing required parameter).', fix: '`error.details.errors` lists each failing field with its location (`loc`) and message. Compare with the parameters table of the endpoint.' }, | |
| 15 | + NOT_FOUND: { status: 404, meaning: 'The requested resource does not exist.', fix: 'Check the path. For data lookups prefer the specific codes below (`TICKER_NOT_FOUND`, `CONTRACT_NOT_FOUND`…).' }, | |
| 16 | + TICKER_NOT_FOUND: { status: 404, meaning: 'Unknown ticker for this asset type / timeframe / adjustment.', fix: 'List what exists with `GET /v1/{asset}/tickers?search=` for the same `timeframe` and `adjustment`. Coverage differs per timeframe (e.g. `UNADJUSTED` exists for 1min and 1day only).' }, | |
| 17 | + ASSET_NOT_FOUND: { status: 404, meaning: 'Unknown asset type.', fix: 'Valid values: `stock`, `etf`, `futures`, `futures_contracts`, `crypto`, `index`, `fx` (see `GET /v1/status`).' }, | |
| 18 | + INVALID_CONTRACT_SYMBOL: { status: 400, meaning: 'Futures contract symbol must look like `ESZ25` or `ESZ2025`.', fix: 'Root (1–3 letters) + month code (F G H J K M N Q U V X Z) + 2- or 4-digit year. See the futures guide for the month code table.' }, | |
| 19 | + CONTRACT_NOT_FOUND: { status: 404, meaning: 'No data for this futures contract.', fix: 'The symbol parses but no file exists — either the expiry was never listed, or it is outside the covered range. `GET /v1/futures/{root}/contracts` returns the full list with first/last data dates.' }, | |
| 20 | + ROOT_NOT_FOUND: { status: 404, meaning: 'Unknown futures root.', fix: '`GET /v1/futures/roots` lists the 140+ roots with names and exchanges. Some FirstRate roots have aliases (e.g. `E6` → `6E`).' }, | |
| 21 | + OPTIONS_UNAVAILABLE: { status: 503, meaning: 'Options dataset not available.', fix: 'The quarter or underlying is not ingested (yet). `GET /v1/options/quarters` and `GET /v1/options/tickers?quarter=` show what exists.' }, | |
| 22 | + INVALID_API_KEY: { status: 401, meaning: 'The API key is missing, malformed, revoked or unknown.', fix: 'Send `Authorization: Bearer hfmd_live_…` (or `?api_key=`). Keys are shown once at creation — rotate from the dashboard if lost. Without a key, requests still work under keyless limits.' }, | |
| 23 | + AUTH_REQUIRED: { status: 401, meaning: 'Authentication is required for this endpoint.', fix: 'Account endpoints (`/v1/me/*`, `/v1/admin/*`) need a signed-in session or an API key.' }, | |
| 24 | + FORBIDDEN: { status: 403, meaning: 'Your tier or role does not allow this action.', fix: 'Admin endpoints require the admin role. Some features may be limited to the high-usage tier — e-mail contact@spboucher.ai.' }, | |
| 25 | + RATE_LIMIT_EXCEEDED: { status: 429, meaning: 'Request or row quota exhausted for the current window.', fix: 'Wait `Retry-After` seconds (also `X-RateLimit-Reset`). `error.type` says which bucket: `requests_per_hour`, `rows_per_minute`… Create a free account to move from 30 req/h to 120 req/min; use Parquet (½ cost), bulk endpoints (free) and ETags (304 = free). See the rate limits guide.' }, | |
| 26 | + ROW_LIMIT_EXCEEDED: { status: 400, meaning: '`limit` is above the maximum rows per request for your tier.', fix: 'Keyless: 5 000 rows/request · free: 50 000 · high usage: 200 000. Lower `limit` and paginate with `cursor`, or switch to `format=csv|parquet` / bulk downloads.' }, | |
| 27 | + CONFLICT: { status: 409, meaning: 'The resource already exists or is in a conflicting state.', fix: 'Typically an e-mail already registered or a key name already used. Pick another value or fetch the existing resource.' }, | |
| 28 | + INTERNAL_ERROR: { status: 500, meaning: 'Unexpected server error.', fix: 'Retry once; if it persists, e-mail contact@spboucher.ai with the full URL and timestamp (UTC).' }, | |
| 29 | + SERVICE_UNAVAILABLE: { status: 503, meaning: 'A dependency (Redis, data lake) is unavailable.', fix: 'Transient. Retry with exponential backoff (1 s, 2 s, 4 s…). The status page reflects incidents.' }, | |
| 30 | +} | |
| 31 | + | |
| 32 | +const EXAMPLE = { | |
| 33 | + error: { code: 'RATE_LIMIT_EXCEEDED', message: 'Request quota exhausted: 30 requests per hour for keyless access. Create a free account for 120 requests per minute.', docs: 'https://www.hfmarketdata.io/docs/errors#rate_limit_exceeded', type: 'requests_per_hour', details: { limit: 30, window: '1h', reset: 1757030400 } }, | |
| 34 | + detail: 'Request quota exhausted: 30 requests per hour for keyless access. Create a free account for 120 requests per minute.', | |
| 35 | +} | |
| 36 | + | |
| 37 | +export default function ErrorsPage() { | |
| 38 | + const { spec, loading } = useSpec() | |
| 39 | + const location = useLocation() | |
| 40 | + useTitle('Error codes', 'Every error code returned by the HF Market Data API, its HTTP status, meaning and how to fix it.') | |
| 41 | + const codes = useMemo(() => { | |
| 42 | + const fromSpec = spec ? errorCodes(spec) : [] | |
| 43 | + const all = new Set([...fromSpec, ...Object.keys(ERROR_INFO)]) | |
| 44 | + return [...all].sort((a, b) => (ERROR_INFO[a]?.status || 400) - (ERROR_INFO[b]?.status || 400) || a.localeCompare(b)) | |
| 45 | + }, [spec]) | |
| 46 | + useEffect(() => { | |
| 47 | + if (!location.hash || loading) return | |
| 48 | + const el = document.getElementById(location.hash.slice(1)) | |
| 49 | + if (el) el.scrollIntoView({ block: 'start' }) | |
| 50 | + }, [location.hash, loading, codes]) | |
| 51 | + const headings = [{ depth: 2, text: 'Envelope', id: 'envelope' }, { depth: 2, text: 'Codes', id: 'codes' }, ...codes.map(c => ({ depth: 3, text: c, id: c.toLowerCase() }))] | |
| 52 | + return ( | |
| 53 | + <DocPage crumbs={[{ label: 'Reference', href: '/docs/reference' }, { label: 'Errors' }]} panel={<OnThisPage headings={headings} />} testId="errors-page"> | |
| 54 | + <header> | |
| 55 | + <h1>Errors</h1> | |
| 56 | + <p className="lead">Every error response — on every endpoint, v1 and v2 — has the same JSON envelope. The <code>error.docs</code> URL points to the matching anchor on this page.</p> | |
| 57 | + </header> | |
| 58 | + <section className="prose"> | |
| 59 | + <h2 id="envelope">Envelope</h2> | |
| 60 | + <Code language="json" title="429 Too Many Requests" code={JSON.stringify(EXAMPLE, null, 2)} /> | |
| 61 | + <ul> | |
| 62 | + <li><code>error.code</code> — stable machine-readable code (this page). Match on it, never on <code>message</code>.</li> | |
| 63 | + <li><code>error.message</code> — human explanation, often with the valid values.</li> | |
| 64 | + <li><code>error.docs</code> — link to the code below.</li> | |
| 65 | + <li><code>error.type</code> — optional sub-type (429: <code>requests_per_hour</code>, <code>requests_per_minute</code>, <code>rows_per_hour</code>, <code>rows_per_minute</code>).</li> | |
| 66 | + <li><code>error.details</code> — optional structured context (validation errors, limits).</li> | |
| 67 | + <li><code>detail</code> — legacy field kept for v1 clients; same text as <code>error.message</code>.</li> | |
| 68 | + </ul> | |
| 69 | + <Callout type="tip" title="Handling strategy">4xx other than 429 are <strong>your</strong> bug — fix and retry immediately. 429 → wait <code>Retry-After</code>. 5xx → exponential backoff. See <Link to="/docs/rate-limits">Rate limits</Link> for code in four languages.</Callout> | |
| 70 | + <h2 id="codes">Codes</h2> | |
| 71 | + {codes.map(c => { | |
| 72 | + const info = ERROR_INFO[c] || { status: 400, meaning: 'See the endpoint description.', fix: '' } | |
| 73 | + return ( | |
| 74 | + <div className="error-code-block" key={c} id={c.toLowerCase()}> | |
| 75 | + <h3><StatusBadge code={info.status} /> {c} <a href={`#${c.toLowerCase()}`} className="anchor" aria-label="Link to this code">#</a></h3> | |
| 76 | + <p><Md text={info.meaning} /></p> | |
| 77 | + {info.fix && <p className="muted"><strong>Fix:</strong> <Md text={info.fix} /></p>} | |
| 78 | + </div> | |
| 79 | + ) | |
| 80 | + })} | |
| 81 | + </section> | |
| 82 | + </DocPage> | |
| 83 | + ) | |
| 84 | +} | |
| 85 | + | |
| 86 | +function Md({ text }) { | |
| 87 | + const parts = text.split(/(`[^`]+`)/g) | |
| 88 | + return <>{parts.map((p, i) => (p.startsWith('`') ? <code key={i}>{p.slice(1, -1)}</code> : p))}</> | |
| 89 | +} | |
added
hfmarketdata/web/src/pages/docs/GuidePage.jsx
+65 −0
@@ -0,0 +1,65 @@ | ||
| 1 | +// MDX guide page. The module may export `snippets` ([{ title, curl, python, javascript, r }]) → right panel, | |
| 2 | +// and `meta` ({ title, description }). Headings come from the build-time index (virtual:guides-index). | |
| 3 | +import React, { useEffect, useState } from 'react' | |
| 4 | +import { MDXProvider } from '@mdx-js/react' | |
| 5 | +import { useLocation } from 'react-router-dom' | |
| 6 | +import CodeTabs from '../../components/CodeTabs.jsx' | |
| 7 | +import { GUIDES, guideBySlug, guideHeadings, guideHref, loadGuide } from '../../docs/guides.js' | |
| 8 | +import { mdxComponents } from '../../docs/mdx-components.jsx' | |
| 9 | +import useTitle from '../../docs/useTitle.js' | |
| 10 | +import { DocPage, OnThisPage, PrevNext } from './DocsLayout.jsx' | |
| 11 | + | |
| 12 | +const cache = new Map() | |
| 13 | + | |
| 14 | +export default function GuidePage({ slug }) { | |
| 15 | + const guide = guideBySlug(slug) | |
| 16 | + const [mod, setMod] = useState(() => cache.get(slug) || null) | |
| 17 | + const [error, setError] = useState(null) | |
| 18 | + const location = useLocation() | |
| 19 | + useTitle(mod?.meta?.title || guide?.title, mod?.meta?.description || guide?.summary) | |
| 20 | + | |
| 21 | + useEffect(() => { | |
| 22 | + if (!guide) return | |
| 23 | + if (cache.has(slug)) { setMod(cache.get(slug)); return } | |
| 24 | + let alive = true | |
| 25 | + setMod(null); setError(null) | |
| 26 | + loadGuide(guide).then(m => { cache.set(slug, m); alive && setMod(m) }, e => alive && setError(e)) | |
| 27 | + return () => { alive = false } | |
| 28 | + }, [slug, guide]) | |
| 29 | + | |
| 30 | + // Scroll to hash once content is in the DOM. | |
| 31 | + useEffect(() => { | |
| 32 | + if (!mod || !location.hash) return | |
| 33 | + const el = document.getElementById(decodeURIComponent(location.hash.slice(1))) | |
| 34 | + if (el) el.scrollIntoView({ block: 'start' }) | |
| 35 | + }, [mod, location.hash]) | |
| 36 | + | |
| 37 | + if (!guide) return <DocPage><h1>Not found</h1><p className="muted">There is no guide at <code>{location.pathname}</code>.</p></DocPage> | |
| 38 | + const idx = GUIDES.indexOf(guide) | |
| 39 | + const prev = GUIDES[idx - 1], next = GUIDES[idx + 1] | |
| 40 | + const headings = guideHeadings(guide) | |
| 41 | + const snippets = mod?.snippets || [] | |
| 42 | + const panel = ( | |
| 43 | + <> | |
| 44 | + <OnThisPage headings={headings} /> | |
| 45 | + {snippets.map((s, i) => <div className="panel-block" key={i}><CodeTabs title={s.title} snippets={s} maxHeight={460} /></div>)} | |
| 46 | + </> | |
| 47 | + ) | |
| 48 | + const Content = mod?.default | |
| 49 | + return ( | |
| 50 | + <DocPage crumbs={[{ label: sectionTitle(guide.section) }, { label: guide.title }]} panel={panel} testId="guide-page"> | |
| 51 | + {error && <p role="alert" className="muted">Could not load this guide ({String(error.message || error)}).</p>} | |
| 52 | + {!mod && !error && <p className="muted" aria-busy="true">Loading…</p>} | |
| 53 | + {Content && ( | |
| 54 | + <div className="prose"> | |
| 55 | + <MDXProvider components={mdxComponents}><Content /></MDXProvider> | |
| 56 | + </div> | |
| 57 | + )} | |
| 58 | + <PrevNext prev={prev && { href: guideHref(prev), title: prev.title }} next={next && { href: guideHref(next), title: next.title }} /> | |
| 59 | + </DocPage> | |
| 60 | + ) | |
| 61 | +} | |
| 62 | + | |
| 63 | +function sectionTitle(id) { | |
| 64 | + return { start: 'Getting started', guides: 'Guides', recipes: 'Recipes', fundamentals: 'Fundamentals', more: 'More' }[id] || 'Docs' | |
| 65 | +} | |
added
hfmarketdata/web/src/pages/docs/OperationPage.jsx
+233 −0
@@ -0,0 +1,233 @@ | ||
| 1 | +// One page per OpenAPI operation: method + path, markdown description, parameters, request samples | |
| 2 | +// (curl / Python / JS / R), response example (spec → captured file → live fetch for public GETs), errors, Try it. | |
| 3 | +import React, { useEffect, useMemo, useState } from 'react' | |
| 4 | +import { Link, useParams } from 'react-router-dom' | |
| 5 | +import { BASE_URL, PUBLIC_BASE } from '../../app/api.js' | |
| 6 | +import Badge, { MethodBadge, StatusBadge } from '../../components/Badge.jsx' | |
| 7 | +import Callout from '../../components/Callout.jsx' | |
| 8 | +import Code from '../../components/Code.jsx' | |
| 9 | +import CodeTabs from '../../components/CodeTabs.jsx' | |
| 10 | +import { PlayIcon } from '../../components/Icons.jsx' | |
| 11 | +import Markdown, { InlineMd } from '../../components/Markdown.jsx' | |
| 12 | +import { buildRequest, exampleParams, hasExampleFile, loadExampleFile, playgroundLink } from '../../docs/enrich.js' | |
| 13 | +import { snippetsFor } from '../../docs/snippets.js' | |
| 14 | +import { errorResponses, listOperations, resolveRef, responseExample, useSpec } from '../../docs/spec.js' | |
| 15 | +import useTitle from '../../docs/useTitle.js' | |
| 16 | +import { DocPage, OnThisPage, PrevNext } from './DocsLayout.jsx' | |
| 17 | + | |
| 18 | +const PUBLIC_TAGS = new Set(['meta', 'bars', 'futures', 'options', 'fundamentals']) | |
| 19 | +const liveCache = new Map() | |
| 20 | + | |
| 21 | +export default function OperationPage() { | |
| 22 | + const { opId } = useParams() | |
| 23 | + const { spec, loading, source } = useSpec() | |
| 24 | + const ops = useMemo(() => (spec ? listOperations(spec) : []), [spec]) | |
| 25 | + const idx = ops.findIndex(o => o.id === opId) | |
| 26 | + const op = ops[idx] | |
| 27 | + useTitle(op ? `${op.summary} — ${op.method} ${op.path}` : 'Reference', op?.description?.split('\n')[0]) | |
| 28 | + | |
| 29 | + if (loading) return <DocPage footer={false}><p className="muted" aria-busy="true">Loading reference…</p></DocPage> | |
| 30 | + if (!op) return <DocPage crumbs={[{ label: 'Reference', href: '/docs/reference' }]}><h1>Unknown operation</h1><p className="muted">No operation <code>{opId}</code> in the current specification. <Link to="/docs/reference">Browse the reference</Link>.</p></DocPage> | |
| 31 | + return <Operation key={op.id} op={op} spec={spec} source={source} prev={ops[idx - 1]} next={ops[idx + 1]} /> | |
| 32 | +} | |
| 33 | + | |
| 34 | +function Operation({ op, spec, source, prev, next }) { | |
| 35 | + const values = useMemo(() => exampleParams(op), [op]) | |
| 36 | + const req = useMemo(() => buildRequest(op, values), [op, values]) | |
| 37 | + const needsAuth = ['auth', 'me', 'admin'].includes(op.tag) || (op.security && op.security.length && !op.security.some(s => Object.keys(s).length === 0)) | |
| 38 | + const isStream = op.tag === 'stream' || /\/stream$/.test(op.path) | |
| 39 | + const snippets = useMemo(() => snippetsFor({ method: op.method, url: req.url, format: req.format, auth: !!needsAuth, stream: isStream }), [op.method, req.url, req.format, needsAuth, isStream]) | |
| 40 | + const specExample = useMemo(() => responseExample(spec, op), [spec, op]) | |
| 41 | + const errors = useMemo(() => errorResponses(spec, op), [spec, op]) | |
| 42 | + const [example, setExample] = useState(specExample ? { ...specExample, origin: 'spec' } : null) | |
| 43 | + | |
| 44 | + useEffect(() => { | |
| 45 | + if (specExample) return | |
| 46 | + let alive = true | |
| 47 | + ;(async () => { | |
| 48 | + const file = await loadExampleFile(op.id) | |
| 49 | + if (file && alive) { setExample({ code: file.status || 200, value: file.body ?? file, origin: 'captured', capturedAt: file.captured_at, url: file.url }); return } | |
| 50 | + if (op.method !== 'GET' || !PUBLIC_TAGS.has(op.tag) || isStream) return | |
| 51 | + if (liveCache.has(op.id)) { alive && setExample(liveCache.get(op.id)); return } | |
| 52 | + try { | |
| 53 | + const ctl = new AbortController(); const t = setTimeout(() => ctl.abort(), 8000) | |
| 54 | + const res = await fetch(BASE_URL + req.rel, { headers: { Accept: 'application/json' }, signal: ctl.signal }) | |
| 55 | + clearTimeout(t) | |
| 56 | + const ct = res.headers.get('content-type') || '' | |
| 57 | + if (!ct.includes('json')) return | |
| 58 | + const body = await res.json() | |
| 59 | + const ex = { code: res.status, value: trimExample(body), origin: 'live', url: PUBLIC_BASE + req.rel } | |
| 60 | + liveCache.set(op.id, ex) | |
| 61 | + alive && setExample(ex) | |
| 62 | + } catch { /* offline — no example */ } | |
| 63 | + })() | |
| 64 | + return () => { alive = false } | |
| 65 | + }, [op.id, op.method, op.tag, req.rel, specExample, isStream]) | |
| 66 | + | |
| 67 | + const successCodes = Object.keys(op.responses).filter(c => c.startsWith('2')) | |
| 68 | + const success = successCodes.map(c => resolveRef(spec, op.responses[c])) | |
| 69 | + const headers = success[0]?.headers || {} | |
| 70 | + const params = op.parameters.filter(p => p.in !== 'header' && p.in !== 'cookie') | |
| 71 | + const body = op.requestBody?.content?.['application/json'] | |
| 72 | + const bodySchema = body ? resolveRef(spec, body.schema) : null | |
| 73 | + const headings = [ | |
| 74 | + { depth: 2, text: 'Parameters', id: 'parameters' }, ...(bodySchema ? [{ depth: 2, text: 'Request body', id: 'request-body' }] : []), | |
| 75 | + { depth: 2, text: 'Response', id: 'response' }, { depth: 2, text: 'Errors', id: 'errors' }, | |
| 76 | + ] | |
| 77 | + | |
| 78 | + const panel = ( | |
| 79 | + <> | |
| 80 | + <div className="panel-block"> | |
| 81 | + <CodeTabs title={`${op.method} request`} snippets={snippets} maxHeight={420} /> | |
| 82 | + </div> | |
| 83 | + <div className="panel-block"> | |
| 84 | + <Code title={example ? `Response · ${example.code}${example.origin === 'live' ? ' · live' : example.origin === 'captured' ? ' · captured' : ''}` : 'Response'} language="json" maxHeight={520} | |
| 85 | + code={example ? JSON.stringify(example.value, null, 2) : `// No example in the spec yet${op.method === 'GET' && PUBLIC_TAGS.has(op.tag) ? ' — fetching a live one…' : ''}`} /> | |
| 86 | + {example?.origin === 'live' && <p className="source-note">Fetched live from <code>{example.url}</code>.</p>} | |
| 87 | + {example?.origin === 'captured' && <p className="source-note">Captured from production{example.capturedAt ? ` on ${example.capturedAt.slice(0, 10)}` : ''} by <code>scripts/fetch-examples.mjs</code>.</p>} | |
| 88 | + </div> | |
| 89 | + <OnThisPage headings={headings} /> | |
| 90 | + </> | |
| 91 | + ) | |
| 92 | + | |
| 93 | + return ( | |
| 94 | + <DocPage crumbs={[{ label: 'Reference', href: '/docs/reference' }, { label: op.tag, href: `/docs/reference/tag/${op.tag}` }]} panel={panel} testId="operation-page"> | |
| 95 | + <header> | |
| 96 | + <h1>{op.summary}</h1> | |
| 97 | + <div className="op-head"> | |
| 98 | + <MethodBadge method={op.method} /> | |
| 99 | + <span className="op-path">{PUBLIC_BASE}{highlightPath(op.path)}</span> | |
| 100 | + {op.deprecated && <Badge tone="warn">Deprecated</Badge>} | |
| 101 | + {needsAuth ? <Badge tone="info">Requires API key</Badge> : <Badge tone="success">Public · keyless OK</Badge>} | |
| 102 | + </div> | |
| 103 | + {op.description ? <Markdown className="prose" text={op.description} headingOffset={2} /> : <p className="muted">No description in the specification yet.</p>} | |
| 104 | + <div className="op-actions"> | |
| 105 | + {!isStream && <Link to={playgroundLink(op, values)} className="btn btn-primary" data-testid="try-it"><PlayIcon /> Try it in the playground</Link>} | |
| 106 | + <a className="btn" href={`${PUBLIC_BASE}/openapi.json`}>OpenAPI JSON</a> | |
| 107 | + </div> | |
| 108 | + </header> | |
| 109 | + | |
| 110 | + <section> | |
| 111 | + <h2 id="parameters">Parameters</h2> | |
| 112 | + {params.length === 0 && <p className="muted">This endpoint takes no parameters.</p>} | |
| 113 | + {params.length > 0 && ( | |
| 114 | + <div className="table-wrap"> | |
| 115 | + <table> | |
| 116 | + <thead><tr><th>Name</th><th>Type</th><th>Default</th><th>Description</th></tr></thead> | |
| 117 | + <tbody> | |
| 118 | + {params.map(p => ( | |
| 119 | + <tr key={`${p.in}-${p.name}`} id={`param-${p.name}`}> | |
| 120 | + <td><span className="param-name">{p.name}</span><span className="param-in">{p.in}</span>{p.required && <span className="param-req">required</span>}</td> | |
| 121 | + <td><span className="param-type">{p.type}</span>{p.constraints.length > 0 && <div className="param-constraints">{p.constraints.join(' · ')}</div>}</td> | |
| 122 | + <td>{p.default !== undefined ? <span className="param-default">{String(p.default)}</span> : <span className="muted">—</span>}</td> | |
| 123 | + <td> | |
| 124 | + <div className="param-desc"><InlineMd text={p.description} /></div> | |
| 125 | + {p.enum && <div className="param-enum">{p.enum.map(v => <code key={String(v)}>{String(v)}</code>)}</div>} | |
| 126 | + {values[p.name] !== undefined && <div className="param-constraints">Example: <code>{values[p.name]}</code></div>} | |
| 127 | + </td> | |
| 128 | + </tr> | |
| 129 | + ))} | |
| 130 | + </tbody> | |
| 131 | + </table> | |
| 132 | + </div> | |
| 133 | + )} | |
| 134 | + </section> | |
| 135 | + | |
| 136 | + {bodySchema && ( | |
| 137 | + <section> | |
| 138 | + <h2 id="request-body">Request body</h2> | |
| 139 | + <p className="muted small">application/json{op.requestBody.required ? ' · required' : ''}</p> | |
| 140 | + <SchemaTable spec={spec} schema={bodySchema} /> | |
| 141 | + {body.example !== undefined && <Code title="Example body" language="json" code={JSON.stringify(body.example, null, 2)} />} | |
| 142 | + </section> | |
| 143 | + )} | |
| 144 | + | |
| 145 | + <section> | |
| 146 | + <h2 id="response">Response</h2> | |
| 147 | + {success.map((r, i) => ( | |
| 148 | + <div key={successCodes[i]} style={{ marginBottom: 12 }}> | |
| 149 | + <p><StatusBadge code={successCodes[i]} /> <InlineMd text={r.description || 'Success'} /></p> | |
| 150 | + {r.content && <p className="muted small">Content types: {Object.keys(r.content).map(ct => <code key={ct} style={{ marginRight: 6 }}>{ct}</code>)}</p>} | |
| 151 | + {r.content?.['application/json']?.schema && <SchemaTable spec={spec} schema={resolveRef(spec, r.content['application/json'].schema)} />} | |
| 152 | + </div> | |
| 153 | + ))} | |
| 154 | + {Object.keys(headers).length > 0 && ( | |
| 155 | + <details> | |
| 156 | + <summary>Response headers ({Object.keys(headers).length})</summary> | |
| 157 | + <dl className="headers-list"> | |
| 158 | + {Object.entries(headers).map(([k, v]) => <React.Fragment key={k}><dt>{k}</dt><dd>{v.description}</dd></React.Fragment>)} | |
| 159 | + </dl> | |
| 160 | + </details> | |
| 161 | + )} | |
| 162 | + {source === 'snapshot' && <Callout type="info" title="Offline specification">The live <code>/openapi.json</code> was not reachable; this page is rendered from the bundled snapshot.</Callout>} | |
| 163 | + </section> | |
| 164 | + | |
| 165 | + <section> | |
| 166 | + <h2 id="errors">Errors</h2> | |
| 167 | + <p className="muted small">Every error uses the <Link to="/docs/errors">uniform envelope</Link> <code>{'{"error": {"code", "message", "docs"}}'}</code>.</p> | |
| 168 | + <div className="errors-list"> | |
| 169 | + {errors.map(e => ( | |
| 170 | + <div className="error-row" key={e.status}> | |
| 171 | + <StatusBadge code={e.status} /> | |
| 172 | + <div> | |
| 173 | + {e.codes.length ? e.codes.map(c => <Link key={c} to={`/docs/errors#${c.toLowerCase()}`} style={{ marginRight: 8 }}><code>{c}</code></Link>) : <InlineMd text={e.description} />} | |
| 174 | + {e.codes.length > 0 && <div className="muted small"><InlineMd text={e.description.replace(/`[A-Z_]+`\s*—\s*/g, '').split('\n\n')[0]} /></div>} | |
| 175 | + </div> | |
| 176 | + </div> | |
| 177 | + ))} | |
| 178 | + </div> | |
| 179 | + </section> | |
| 180 | + | |
| 181 | + <PrevNext prev={prev && { href: `/docs/reference/${prev.id}`, title: prev.summary }} next={next && { href: `/docs/reference/${next.id}`, title: next.summary }} /> | |
| 182 | + </DocPage> | |
| 183 | + ) | |
| 184 | +} | |
| 185 | + | |
| 186 | +function highlightPath(path) { | |
| 187 | + return path.split(/(\{[^}]+\})/g).map((part, i) => (part.startsWith('{') ? <span className="var" key={i}>{part}</span> : part)) | |
| 188 | +} | |
| 189 | + | |
| 190 | +function trimExample(body, maxRows = 5) { | |
| 191 | + if (body && typeof body === 'object' && Array.isArray(body.data) && body.data.length > maxRows) { | |
| 192 | + return { ...body, data: [...body.data.slice(0, maxRows), `… ${body.data.length - maxRows} more rows`] } | |
| 193 | + } | |
| 194 | + return body | |
| 195 | +} | |
| 196 | + | |
| 197 | +/** Flat property table for an object schema (one level, arrays of objects expanded once). */ | |
| 198 | +export function SchemaTable({ spec, schema, depth = 0 }) { | |
| 199 | + const s = resolveRef(spec, schema) | |
| 200 | + if (!s || typeof s !== 'object') return null | |
| 201 | + const props = s.properties || (s.items && resolveRef(spec, s.items)?.properties) | |
| 202 | + if (!props) { | |
| 203 | + if (s.type || s.description) return <p className="muted small">{s.type || 'object'}{s.description ? ` — ${s.description}` : ''}</p> | |
| 204 | + return null | |
| 205 | + } | |
| 206 | + const required = new Set(s.required || resolveRef(spec, s.items)?.required || []) | |
| 207 | + return ( | |
| 208 | + <div className="table-wrap" style={{ marginBottom: 12 }}> | |
| 209 | + <table> | |
| 210 | + <thead><tr><th>Field</th><th>Type</th><th>Description</th></tr></thead> | |
| 211 | + <tbody> | |
| 212 | + {Object.entries(props).map(([name, p0]) => { | |
| 213 | + const p = resolveRef(spec, p0) || {} | |
| 214 | + const type = p.type ? (Array.isArray(p.type) ? p.type.join(' | ') : p.type) : p.anyOf ? p.anyOf.map(x => resolveRef(spec, x)?.type).filter(Boolean).join(' | ') : p.$ref ? 'object' : 'any' | |
| 215 | + const inner = (p.type === 'object' || p.type === 'array') && depth < 1 && (p.properties || resolveRef(spec, p.items)?.properties) | |
| 216 | + return ( | |
| 217 | + <tr key={name}> | |
| 218 | + <td><span className="param-name">{name}</span>{required.has(name) && <span className="param-req">required</span>}</td> | |
| 219 | + <td><span className="param-type">{type}{p.type === 'array' && p.items ? `<${resolveRef(spec, p.items)?.type || 'object'}>` : ''}{p.format ? ` (${p.format})` : ''}</span></td> | |
| 220 | + <td> | |
| 221 | + <div className="param-desc"><InlineMd text={p.description || ''} /></div> | |
| 222 | + {p.enum && <div className="param-enum">{p.enum.map(v => <code key={String(v)}>{String(v)}</code>)}</div>} | |
| 223 | + {p.example !== undefined && <div className="param-constraints">Example: <code>{JSON.stringify(p.example)}</code></div>} | |
| 224 | + {inner && <SchemaTable spec={spec} schema={p} depth={depth + 1} />} | |
| 225 | + </td> | |
| 226 | + </tr> | |
| 227 | + ) | |
| 228 | + })} | |
| 229 | + </tbody> | |
| 230 | + </table> | |
| 231 | + </div> | |
| 232 | + ) | |
| 233 | +} | |
added
hfmarketdata/web/src/pages/docs/ReferenceIndex.jsx
+55 −0
@@ -0,0 +1,55 @@ | ||
| 1 | +// Reference index (all tags) and per-tag page, generated from the spec. | |
| 2 | +import React, { useMemo } from 'react' | |
| 3 | +import { Link, useParams, useSearchParams } from 'react-router-dom' | |
| 4 | +import { MethodBadge } from '../../components/Badge.jsx' | |
| 5 | +import Callout from '../../components/Callout.jsx' | |
| 6 | +import Markdown from '../../components/Markdown.jsx' | |
| 7 | +import { groupByTag, listOperations, specVersion, useSpec } from '../../docs/spec.js' | |
| 8 | +import useTitle from '../../docs/useTitle.js' | |
| 9 | +import { DocPage, OnThisPage } from './DocsLayout.jsx' | |
| 10 | + | |
| 11 | +export default function ReferenceIndex() { | |
| 12 | + const { tag } = useParams() | |
| 13 | + const [sp] = useSearchParams() | |
| 14 | + const { spec, loading, source } = useSpec() | |
| 15 | + const tags = useMemo(() => (spec ? groupByTag(spec, listOperations(spec)) : []), [spec]) | |
| 16 | + const shown = tag ? tags.filter(t => t.name === tag) : tags | |
| 17 | + useTitle(tag ? `${cap(tag)} — API reference` : 'API reference', 'Every endpoint of the HF Market Data API, generated from the OpenAPI 3.1 specification.') | |
| 18 | + | |
| 19 | + // <Endpoint> chips from guides may link with ?find=METHOD /path when the operationId is unknown. | |
| 20 | + const find = sp.get('find') | |
| 21 | + const found = find && spec ? listOperations(spec).find(o => `${o.method} ${o.path}` === find) : null | |
| 22 | + | |
| 23 | + if (loading) return <DocPage footer={false}><p className="muted" aria-busy="true">Loading specification…</p></DocPage> | |
| 24 | + const headings = shown.map(t => ({ depth: 2, text: cap(t.name), id: `tag-${t.name}` })) | |
| 25 | + return ( | |
| 26 | + <DocPage crumbs={tag ? [{ label: 'Reference', href: '/docs/reference' }, { label: tag }] : [{ label: 'Reference' }]} panel={<OnThisPage headings={headings} />} testId="reference-index"> | |
| 27 | + <header> | |
| 28 | + <h1>{tag ? cap(tag) : 'API reference'}</h1> | |
| 29 | + {!tag && <p className="lead">Every endpoint, generated from <a href="/openapi.json">openapi.json</a> (OpenAPI 3.1{specVersion(spec) ? `, API v${specVersion(spec)}` : ''}). Each page shows parameters, live examples, error codes and a one-click <em>Try it</em>.</p>} | |
| 30 | + {found && <Callout type="tip" title="You were looking for">{found.method} <code>{found.path}</code> → <Link to={`/docs/reference/${found.id}`}>{found.summary}</Link></Callout>} | |
| 31 | + {source === 'snapshot' && <Callout type="info">Rendered from the bundled specification snapshot — the live API was not reachable.</Callout>} | |
| 32 | + </header> | |
| 33 | + {shown.length === 0 && <p className="muted">No endpoints for this tag.</p>} | |
| 34 | + {shown.map(t => ( | |
| 35 | + <section className="ref-tag" key={t.name}> | |
| 36 | + <h2 id={`tag-${t.name}`}>{cap(t.name)} <span className="muted small">{t.ops.length} endpoint{t.ops.length > 1 ? 's' : ''}</span></h2> | |
| 37 | + {t.description && <Markdown text={t.description} />} | |
| 38 | + <div className="ref-op-list"> | |
| 39 | + {t.ops.map(op => ( | |
| 40 | + <Link key={op.id} to={`/docs/reference/${op.id}`} className="ref-op"> | |
| 41 | + <MethodBadge method={op.method} /> | |
| 42 | + <div> | |
| 43 | + <div className="op-path">{op.path}</div> | |
| 44 | + <div className="sum">{op.summary}</div> | |
| 45 | + </div> | |
| 46 | + </Link> | |
| 47 | + ))} | |
| 48 | + </div> | |
| 49 | + </section> | |
| 50 | + ))} | |
| 51 | + </DocPage> | |
| 52 | + ) | |
| 53 | +} | |
| 54 | + | |
| 55 | +const cap = s => (s ? s.charAt(0).toUpperCase() + s.slice(1) : s) | |
added
hfmarketdata/web/src/pages/docs/docs.css
+137 −0
@@ -0,0 +1,137 @@ | ||
| 1 | +/* Docs: 3 columns (nav · content · code panel). DOM order = visual order on every breakpoint. */ | |
| 2 | +.docs { display: grid; grid-template-columns: 250px minmax(0, 1fr); flex: 1; width: 100%; max-width: 1480px; margin: 0 auto; } | |
| 3 | +.docs-nav { position: sticky; top: var(--topbar-h); align-self: start; height: calc(100dvh - var(--topbar-h)); overflow-y: auto; padding: 22px 12px 40px 20px; border-right: 1px solid var(--line); scrollbar-width: thin; } | |
| 4 | +.docs-nav-section { margin-bottom: 18px; } | |
| 5 | +.docs-nav-title { font-size: 11px; font-weight: 700; letter-spacing: .1em; text-transform: uppercase; color: var(--fg-3); padding: 0 8px; margin-bottom: 6px; } | |
| 6 | +.docs-nav a { display: block; padding: 5px 8px; border-radius: 6px; color: var(--fg-1); font-size: 13.5px; line-height: 1.35; } | |
| 7 | +.docs-nav a:hover { color: var(--fg); background: var(--bg-2); text-decoration: none; } | |
| 8 | +.docs-nav a.active { color: var(--fg); background: var(--accent-soft); font-weight: 550; } | |
| 9 | +.docs-nav .nav-op { display: flex; align-items: center; gap: 7px; padding-left: 8px; font-size: 13px; } | |
| 10 | +.docs-nav .nav-op .method { min-width: 0; font-size: 9.5px; padding: 1px 4px; } | |
| 11 | +.docs-nav details { margin-bottom: 2px; } | |
| 12 | +.docs-nav summary { list-style: none; cursor: pointer; display: flex; align-items: center; justify-content: space-between; gap: 6px; padding: 5px 8px; border-radius: 6px; color: var(--fg-1); font-size: 13.5px; font-weight: 500; } | |
| 13 | +.docs-nav summary::-webkit-details-marker { display: none; } | |
| 14 | +.docs-nav summary:hover { background: var(--bg-2); color: var(--fg); } | |
| 15 | +.docs-nav summary svg { width: 14px; height: 14px; transition: transform .15s; color: var(--fg-3); } | |
| 16 | +.docs-nav details[open] summary svg { transform: rotate(90deg); } | |
| 17 | +.docs-nav details > div { padding-left: 6px; border-left: 1px solid var(--line); margin-left: 12px; } | |
| 18 | +.docs-nav-search { display: flex; align-items: center; gap: 8px; width: 100%; margin-bottom: 16px; padding: 7px 10px; border-radius: 8px; border: 1px solid var(--line-2); background: var(--bg-1); color: var(--fg-2); font-size: 13px; cursor: text; text-align: left; } | |
| 19 | +.docs-nav-search kbd { margin-left: auto; } | |
| 20 | +.docs-main { min-width: 0; } | |
| 21 | +.docpage { display: grid; grid-template-columns: minmax(0, 1fr) minmax(360px, 440px); gap: 0; min-height: calc(100dvh - var(--topbar-h)); } | |
| 22 | +.docpage.no-panel { grid-template-columns: minmax(0, 1fr); } | |
| 23 | +.docs-content { min-width: 0; padding: 34px 40px 80px; max-width: 820px; } | |
| 24 | +.docs-content > header { margin-bottom: 26px; } | |
| 25 | +.docs-content h1 { font-size: clamp(26px, 3vw, 34px); margin-bottom: 8px; } | |
| 26 | +.docs-crumbs { display: flex; gap: 6px; align-items: center; color: var(--fg-2); font-size: 13px; margin-bottom: 12px; flex-wrap: wrap; } | |
| 27 | +.docs-crumbs a { color: var(--fg-2); } | |
| 28 | +.docs-crumbs svg { width: 12px; height: 12px; } | |
| 29 | +.docs-panel { border-left: 1px solid var(--line); background: var(--bg-1); padding: 26px 22px 60px; } | |
| 30 | +.docs-panel-inner { position: sticky; top: calc(var(--topbar-h) + 18px); display: grid; gap: 18px; max-height: calc(100dvh - var(--topbar-h) - 36px); overflow-y: auto; scrollbar-width: thin; padding-right: 2px; } | |
| 31 | +.docs-panel .code { background: var(--code-bg); } | |
| 32 | +.docs-panel h4 { font-size: 11.5px; letter-spacing: .08em; text-transform: uppercase; color: var(--fg-3); margin: 0 0 8px; } | |
| 33 | +.panel-block { display: grid; gap: 6px; } | |
| 34 | +.docs-footer { margin-top: 48px; padding-top: 18px; border-top: 1px solid var(--line); display: flex; justify-content: space-between; flex-wrap: wrap; gap: 12px; color: var(--fg-2); font-size: 13px; } | |
| 35 | +.docs-footer a { display: inline-flex; align-items: center; gap: 5px; color: var(--fg-1); } | |
| 36 | +.docs-footer svg { width: 14px; height: 14px; } | |
| 37 | +.docs-prevnext { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; margin-top: 36px; } | |
| 38 | +.docs-prevnext a { display: block; padding: 14px 16px; border: 1px solid var(--line); border-radius: var(--radius); color: var(--fg); } | |
| 39 | +.docs-prevnext a:hover { border-color: var(--line-3); text-decoration: none; background: var(--bg-1); } | |
| 40 | +.docs-prevnext .lbl { display: block; font-size: 12px; color: var(--fg-2); margin-bottom: 4px; } | |
| 41 | +.docs-prevnext .next { text-align: right; } | |
| 42 | + | |
| 43 | +/* prose (MDX + markdown descriptions) */ | |
| 44 | +.prose { font-size: 15.5px; line-height: 1.7; color: var(--fg); } | |
| 45 | +.prose > p:first-of-type { color: var(--fg-1); } | |
| 46 | +.prose h2 { margin-top: 2.2em; padding-top: .3em; font-size: 22px; } | |
| 47 | +.prose h3 { margin-top: 1.8em; font-size: 17px; } | |
| 48 | +.prose h4 { margin-top: 1.4em; font-size: 15px; } | |
| 49 | +.prose ul, .prose ol { padding-left: 1.4em; margin: 0 0 1em; } | |
| 50 | +.prose li + li { margin-top: 5px; } | |
| 51 | +.prose li > p { margin: 0; } | |
| 52 | +.prose img { border-radius: var(--radius); border: 1px solid var(--line); } | |
| 53 | +.prose .table-wrap { margin: 0 0 20px; } | |
| 54 | +.prose table { font-size: 13.5px; } | |
| 55 | +.prose th, .prose td { padding: 8px 11px; } | |
| 56 | +.prose blockquote { margin: 0 0 16px; padding: 10px 16px; border-left: 3px solid var(--accent); background: var(--bg-1); border-radius: 0 var(--radius) var(--radius) 0; color: var(--fg-1); } | |
| 57 | +.prose hr { margin: 36px 0; } | |
| 58 | +.prose .code, .prose .codetabs, .prose .callout { margin: 16px 0 22px; } | |
| 59 | +.anchor-heading { position: relative; } | |
| 60 | +.anchor-heading .anchor { position: absolute; left: -1.1em; color: var(--fg-3); opacity: 0; font-weight: 400; padding-right: .3em; } | |
| 61 | +.anchor-heading:hover .anchor, .anchor-heading .anchor:focus { opacity: 1; text-decoration: none; } | |
| 62 | +.figure { margin: 18px 0 26px; } | |
| 63 | +.figure svg { display: block; width: 100%; height: auto; } | |
| 64 | +.figure figcaption { font-size: 13px; color: var(--fg-2); margin-top: 8px; text-align: center; } | |
| 65 | +.endpoint-chip { display: inline-flex; align-items: center; gap: 8px; padding: 4px 10px 4px 5px; border: 1px solid var(--line); border-radius: 8px; background: var(--bg-1); color: var(--fg); margin: 2px 4px 6px 0; font-size: 13px; } | |
| 66 | +.endpoint-chip:hover { border-color: var(--line-3); text-decoration: none; } | |
| 67 | +.endpoint-chip code { background: none; border: 0; padding: 0; } | |
| 68 | + | |
| 69 | +/* on this page */ | |
| 70 | +.toc a { display: block; padding: 3px 0 3px 10px; border-left: 2px solid var(--line); color: var(--fg-2); font-size: 13px; line-height: 1.4; } | |
| 71 | +.toc a.depth-3 { padding-left: 22px; } | |
| 72 | +.toc a:hover { color: var(--fg); text-decoration: none; } | |
| 73 | +.toc a.active { color: var(--fg); border-left-color: var(--accent); } | |
| 74 | + | |
| 75 | +/* operation page */ | |
| 76 | +.op-head { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin: 10px 0 14px; } | |
| 77 | +.op-path { font-family: var(--mono); font-size: 15px; word-break: break-all; } | |
| 78 | +.op-path .var { color: var(--accent-2); } | |
| 79 | +.op-actions { display: flex; gap: 8px; flex-wrap: wrap; margin: 14px 0 26px; } | |
| 80 | +.param-name { font-family: var(--mono); font-size: 13px; font-weight: 600; } | |
| 81 | +.param-in { font-size: 11px; color: var(--fg-3); margin-left: 6px; text-transform: uppercase; letter-spacing: .04em; } | |
| 82 | +.param-type { font-family: var(--mono); font-size: 12px; color: var(--fg-2); } | |
| 83 | +.param-req { color: var(--danger); font-size: 11px; font-weight: 600; margin-left: 6px; } | |
| 84 | +.param-default { font-family: var(--mono); font-size: 12px; color: var(--fg-2); } | |
| 85 | +.param-enum { display: flex; gap: 4px; flex-wrap: wrap; margin-top: 4px; } | |
| 86 | +.param-enum code { font-size: 11.5px; } | |
| 87 | +.param-desc { color: var(--fg-1); font-size: 13.5px; } | |
| 88 | +.param-desc p { margin: 0; } | |
| 89 | +.param-constraints { font-size: 12px; color: var(--fg-3); margin-top: 3px; } | |
| 90 | +.errors-list { display: grid; gap: 6px; } | |
| 91 | +.error-row { display: grid; grid-template-columns: 52px 1fr; gap: 12px; align-items: start; padding: 8px 0; border-bottom: 1px solid var(--line); font-size: 13.5px; } | |
| 92 | +.error-row code { font-size: 12px; } | |
| 93 | +.headers-list dt { font-family: var(--mono); font-size: 12.5px; font-weight: 600; margin-top: 8px; } | |
| 94 | +.headers-list dd { margin: 2px 0 0; color: var(--fg-1); font-size: 13px; } | |
| 95 | +.source-note { font-size: 12px; color: var(--fg-3); } | |
| 96 | + | |
| 97 | +/* reference index */ | |
| 98 | +.ref-tag { margin-bottom: 36px; } | |
| 99 | +.ref-tag h2 { text-transform: capitalize; display: flex; align-items: baseline; gap: 10px; } | |
| 100 | +.ref-op-list { display: grid; gap: 6px; } | |
| 101 | +.ref-op { display: grid; grid-template-columns: 56px 1fr; gap: 12px; align-items: baseline; padding: 10px 12px; border: 1px solid var(--line); border-radius: var(--radius); color: var(--fg); } | |
| 102 | +.ref-op:hover { border-color: var(--line-3); background: var(--bg-1); text-decoration: none; } | |
| 103 | +.ref-op .op-path { font-size: 13.5px; } | |
| 104 | +.ref-op .sum { color: var(--fg-2); font-size: 13px; margin-top: 2px; } | |
| 105 | + | |
| 106 | +/* docs home */ | |
| 107 | +.docs-home-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; margin: 18px 0 30px; } | |
| 108 | +.docs-home-card { display: block; padding: 16px 18px; border: 1px solid var(--line); border-radius: var(--radius-lg); color: var(--fg); background: var(--bg-1); } | |
| 109 | +.docs-home-card:hover { border-color: var(--line-3); text-decoration: none; } | |
| 110 | +.docs-home-card h3 { font-size: 15px; margin-bottom: 4px; } | |
| 111 | +.docs-home-card p { margin: 0; color: var(--fg-2); font-size: 13.5px; } | |
| 112 | + | |
| 113 | +/* error code page */ | |
| 114 | +.error-code-block { padding: 16px 0 22px; border-bottom: 1px solid var(--line); } | |
| 115 | +.error-code-block h3 { font-family: var(--mono); font-size: 15px; display: flex; align-items: center; gap: 10px; flex-wrap: wrap; } | |
| 116 | + | |
| 117 | +/* mobile nav toggle */ | |
| 118 | +.docs-nav-toggle { display: none; } | |
| 119 | + | |
| 120 | +@media (max-width: 1180px) { | |
| 121 | + .docpage { grid-template-columns: minmax(0, 1fr); } | |
| 122 | + .docs-panel { border-left: 0; border-top: 1px solid var(--line); padding: 22px 40px 50px; } | |
| 123 | + .docs-panel-inner { position: static; max-height: none; overflow: visible; } | |
| 124 | + .docs-content { max-width: 900px; } | |
| 125 | +} | |
| 126 | +@media (max-width: 900px) { | |
| 127 | + .docs { grid-template-columns: 1fr; } | |
| 128 | + .docs-nav { display: none; } | |
| 129 | + .docs-nav.open { display: block; position: fixed; inset: var(--topbar-h) 0 0 0; z-index: 45; height: auto; background: var(--bg); border-right: 0; padding: 16px; } | |
| 130 | + .docs-nav-toggle { display: flex; align-items: center; gap: 8px; padding: 10px 16px; border-bottom: 1px solid var(--line); background: var(--bg-1); position: sticky; top: var(--topbar-h); z-index: 40; font-size: 13.5px; color: var(--fg-1); } | |
| 131 | + .docs-nav-toggle button { display: inline-flex; align-items: center; gap: 6px; } | |
| 132 | + .docs-content { padding: 24px 16px 60px; } | |
| 133 | + .docs-panel { padding: 18px 16px 40px; } | |
| 134 | + .docs-prevnext { grid-template-columns: 1fr; } | |
| 135 | + .docs-home-grid { grid-template-columns: 1fr; } | |
| 136 | + .anchor-heading .anchor { display: none; } | |
| 137 | +} | |
modified
hfmarketdata/web/vite.config.js
+57 −5
@@ -1,18 +1,70 @@ | ||
| 1 | +import { readdirSync, readFileSync } from 'node:fs' | |
| 2 | +import { join, resolve } from 'node:path' | |
| 1 | 3 | import { defineConfig } from 'vite' |
| 2 | 4 | import react from '@vitejs/plugin-react' |
| 3 | 5 | import mdx from '@mdx-js/rollup' |
| 4 | 6 | |
| 7 | +const CONTENT = resolve(__dirname, 'content') | |
| 8 | + | |
| 9 | +// Same slug rule as the MDX heading components (src/docs/mdx-components.jsx) — anchors must match search results. | |
| 10 | +export const slugify = s => s.toLowerCase().replace(/[`*_]/g, '').replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '') | |
| 11 | + | |
| 12 | +function extractHeadings(src) { | |
| 13 | + const out = [] | |
| 14 | + let inFence = false | |
| 15 | + for (const line of src.split('\n')) { | |
| 16 | + if (/^\s*(```|~~~)/.test(line)) { inFence = !inFence; continue } | |
| 17 | + if (inFence) continue | |
| 18 | + const m = /^(#{2,3})\s+(.+?)\s*$/.exec(line) | |
| 19 | + if (m) out.push({ depth: m[1].length, text: m[2].replace(/[`*_]/g, ''), id: slugify(m[2]) }) | |
| 20 | + } | |
| 21 | + return out | |
| 22 | +} | |
| 23 | + | |
| 24 | +// virtual:guides-index → { [file]: [{ depth, text, id }] } for every content/guides/*.mdx + content/changelog.mdx. | |
| 25 | +// Lets search and the "On this page" outline work without loading every guide chunk. | |
| 26 | +function guidesIndexPlugin() { | |
| 27 | + const id = 'virtual:guides-index' | |
| 28 | + const resolved = '\0' + id | |
| 29 | + const build = () => { | |
| 30 | + const index = {} | |
| 31 | + for (const f of readdirSync(join(CONTENT, 'guides')).filter(f => f.endsWith('.mdx'))) { | |
| 32 | + index[f.replace(/\.mdx$/, '')] = extractHeadings(readFileSync(join(CONTENT, 'guides', f), 'utf8')) | |
| 33 | + } | |
| 34 | + try { index.changelog = extractHeadings(readFileSync(join(CONTENT, 'changelog.mdx'), 'utf8')) } catch { index.changelog = [] } | |
| 35 | + return index | |
| 36 | + } | |
| 37 | + return { | |
| 38 | + name: 'hfmd-guides-index', | |
| 39 | + resolveId(source) { return source === id ? resolved : null }, | |
| 40 | + load(source) { | |
| 41 | + if (source !== resolved) return null | |
| 42 | + this.addWatchFile(join(CONTENT, 'guides')) | |
| 43 | + return `export default ${JSON.stringify(build())}` | |
| 44 | + }, | |
| 45 | + handleHotUpdate({ file, server }) { | |
| 46 | + if (file.startsWith(CONTENT)) { | |
| 47 | + const mod = server.moduleGraph.getModuleById(resolved) | |
| 48 | + if (mod) server.moduleGraph.invalidateModule(mod) | |
| 49 | + } | |
| 50 | + }, | |
| 51 | + } | |
| 52 | +} | |
| 53 | + | |
| 5 | 54 | // Dev proxy → local API (uvicorn on :8090). In production the API serves dist/ itself (SPA fallback in main.py). |
| 6 | 55 | export default defineConfig({ |
| 7 | − plugins: [{ enforce: 'pre', ...mdx({ providerImportSource: '@mdx-js/react' }) }, react()], | |
| 56 | + plugins: [guidesIndexPlugin(), { enforce: 'pre', ...mdx({ providerImportSource: '@mdx-js/react' }) }, react()], | |
| 8 | 57 | build: { |
| 9 | 58 | target: 'es2020', |
| 59 | + cssCodeSplit: true, | |
| 10 | 60 | rollupOptions: { |
| 11 | 61 | output: { |
| 12 | − manualChunks: { | |
| 13 | − vendor: ['react', 'react-dom', 'react-router-dom'], | |
| 14 | − charts: ['lightweight-charts'], | |
| 15 | − prism: ['prism-react-renderer'], | |
| 62 | + manualChunks(id) { | |
| 63 | + if (id.includes('node_modules/lightweight-charts')) return 'charts' | |
| 64 | + if (id.includes('node_modules/prism-react-renderer')) return 'prism' | |
| 65 | + if (id.includes('node_modules/@mdx-js')) return 'mdx' | |
| 66 | + if (/node_modules\/(react|react-dom|react-router|react-router-dom|scheduler|@remix-run)\//.test(id)) return 'vendor' | |
| 67 | + return undefined | |
| 16 | 68 | }, |
| 17 | 69 | }, |
| 18 | 70 | }, |
| 19 | 71 | |