@opsydyn/elysia-spectral 1.1.1 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,29 @@
1
1
  # Changelog
2
2
 
3
+ ## [1.3.0](https://github.com/opsydyn/elysia-spectral/compare/v1.2.0...v1.3.0) (2026-04-28)
4
+
5
+
6
+ ### Features
7
+
8
+ * **dashboard:** add theme switcher and extract dashboard assets ([08217c8](https://github.com/opsydyn/elysia-spectral/commit/08217c8e786220ca1f5b613ac18e2d266ee8d928))
9
+
10
+
11
+ ### Bug Fixes
12
+
13
+ * **dashboard:** label theme switcher visibly ([fb74c82](https://github.com/opsydyn/elysia-spectral/commit/fb74c828d31063cb4d667578789811fcb1f35642))
14
+
15
+ ## [1.2.0](https://github.com/opsydyn/elysia-spectral/compare/v1.1.1...v1.2.0) (2026-04-28)
16
+
17
+
18
+ ### Features
19
+
20
+ * optional bearer auth for the lint dashboard route ([7360e1c](https://github.com/opsydyn/elysia-spectral/commit/7360e1c323328005160598832dbd02b315a0cd2f))
21
+
22
+
23
+ ### Bug Fixes
24
+
25
+ * publish dashboard documentation update ([c8d656a](https://github.com/opsydyn/elysia-spectral/commit/c8d656ae1a6bbcd67ce63bd8e430861b07cd216b))
26
+
3
27
  ## [1.1.1](https://github.com/opsydyn/elysia-spectral/compare/v1.1.0...v1.1.1) (2026-04-28)
4
28
 
5
29
 
package/README.md CHANGED
@@ -62,6 +62,7 @@ Current package scope:
62
62
  - Bruno collection output (OpenCollection YAML or JSON)
63
63
  - reusable runtime for CI and tests
64
64
  - opt-in healthcheck endpoint for cached and fresh runs
65
+ - opt-in HTML lint dashboard with severity filters, search, and copy-friendly artifact paths
65
66
 
66
67
  ## Data flow
67
68
 
@@ -347,6 +348,60 @@ Behavior:
347
348
  - the route returns `503` when findings meet or exceed `failOn`
348
349
  - the route is hidden from generated OpenAPI docs
349
350
 
351
+ ### Add an HTML lint dashboard endpoint
352
+
353
+ The dashboard route is opt-in and renders the latest lint result as a self-contained HTML page — no JS bundle, no build step, no external assets.
354
+
355
+ ```ts
356
+ spectralPlugin({
357
+ dashboard: {
358
+ path: '/__openapi/dashboard'
359
+ }
360
+ })
361
+ ```
362
+
363
+ `dashboard: {}` mounts the default path (`/__openapi/dashboard`); pass `path` to override.
364
+
365
+ To gate the dashboard behind a static bearer token, pass `bearerToken`:
366
+
367
+ ```ts
368
+ spectralPlugin({
369
+ dashboard: {
370
+ path: '/__openapi/dashboard',
371
+ bearerToken: process.env.LINT_DASHBOARD_TOKEN
372
+ }
373
+ })
374
+ ```
375
+
376
+ When `bearerToken` is set the route returns `401 Unauthorized` (with a `WWW-Authenticate: Bearer` header) unless the request carries a matching `Authorization: Bearer <token>` header. Leave `bearerToken` undefined to keep the route open — useful in local dev. This mirrors the bearer pattern documented in the [Elysia OpenAPI guide](https://elysiajs.com/patterns/openapi).
377
+
378
+ What it surfaces:
379
+
380
+ - pass / fail banner at the configured `failOn` threshold
381
+ - run metadata (timestamp, source, duration, cache hit)
382
+ - severity summary chips that filter the findings table
383
+ - a search input that matches rule code, operation, message, and JSON pointer
384
+ - copy-to-clipboard buttons next to artifact paths
385
+ - the trademarked `SPEC IS TIGHT, SHIP IT RIGHT` tagline on a clean run
386
+
387
+ Keyboard shortcuts: `r` re-runs, `/` focuses the filter, `Enter`/`Space` toggles the focused severity chip.
388
+
389
+ Three dark themes ship with the dashboard, selectable from the header dropdown and persisted per-browser via `localStorage`:
390
+
391
+ - **Astro Houston** (default) — purple / blue gradient
392
+ - **Tron Legacy** — cyan neon on near-black
393
+ - **Detroit 808** — TR-808 amber, red, and yellow
394
+
395
+ Append `?fresh=1` to force a fresh lint run instead of returning the cached result.
396
+
397
+ | State | Screenshot |
398
+ | --- | --- |
399
+ | Pass | ![Dashboard pass state](https://raw.githubusercontent.com/opsydyn/elysia-spectral/main/docs/screenshots/dashboard-happy.png) |
400
+ | Fail | ![Dashboard fail state](https://raw.githubusercontent.com/opsydyn/elysia-spectral/main/docs/screenshots/dashboard-unhappy.png) |
401
+ | Recovered | ![Dashboard recovered state](https://raw.githubusercontent.com/opsydyn/elysia-spectral/main/docs/screenshots/dashboard-unhappy-fixed.png) |
402
+
403
+ The dashboard is hidden from generated OpenAPI docs and is intended for local and internal-only environments. Gate it behind your standard auth or environment checks before exposing it on a public host.
404
+
350
405
  ### Persist JSON reports and OpenAPI snapshots
351
406
 
352
407
  ```ts
@@ -651,6 +706,7 @@ That starts `apps/dev-app` with:
651
706
  - OpenAPI UI at `/openapi`
652
707
  - raw OpenAPI JSON at `/openapi/json`
653
708
  - opt-in lint healthcheck at `/health/openapi-lint`
709
+ - opt-in HTML lint dashboard at `/api-lint/dashboard`
654
710
  - JSON lint report output at `./artifacts/openapi-lint.json`
655
711
  - OpenAPI snapshot output at `./elysia-spectral-dev-app.open-api.json`
656
712
 
@@ -667,7 +723,7 @@ That example uses `startup.mode: 'report'`, so the app still boots while the pac
667
723
  ### Package API
668
724
 
669
725
  ```ts
670
- // ── Vocabulary types ──────────────────────────────────────────────────────────
726
+ // ── Ubiquitous language types ──────────────────────────────────────────────────────────
671
727
 
672
728
  type PresetName = 'recommended' | 'server' | 'strict'
673
729
  type LintSeverity = 'error' | 'warn' | 'info' | 'hint'
@@ -687,6 +743,7 @@ type SpectralPluginOptions = {
687
743
  /** Severity level at which the lint run is considered failed. Defaults to 'error'. */
688
744
  failOn?: SeverityThreshold
689
745
  healthcheck?: false | { path?: string }
746
+ dashboard?: false | { path?: string; bearerToken?: string }
690
747
  output?: {
691
748
  /** Print findings to the console. Default: true. */
692
749
  console?: boolean
@@ -973,8 +1030,9 @@ Startup linting and route exposure solve different problems:
973
1030
 
974
1031
  - startup linting protects boot and local feedback loops
975
1032
  - healthchecks expose operational state to external callers
1033
+ - the dashboard turns the same cached result into a human-readable page
976
1034
 
977
- Separating them avoids a production surprise where enabling linting also adds a route you did not intend to expose.
1035
+ Separating them avoids a production surprise where enabling linting also adds a route you did not intend to expose. Each route is opt-in, so dashboards stay off by default and never leak into generated OpenAPI docs.
978
1036
 
979
1037
  ### Why repo-level rulesets are the default customization path
980
1038
 
@@ -1,2 +1,2 @@
1
- import { a as createOpenApiLintRuntime, c as ResolvedRulesetCandidate, d as RulesetResolverContext, f as RulesetResolverInput, g as lintOpenApi, h as loadRuleset, i as OpenApiLintArtifactWriteError, l as RulesetLoadError, m as loadResolvedRuleset, n as enforceThreshold, o as LoadResolvedRulesetOptions, p as defaultRulesetResolvers, r as shouldFail, s as LoadedRuleset, t as OpenApiLintThresholdError, u as RulesetResolver } from "../index-Dx83hCT9.mjs";
1
+ import { a as createOpenApiLintRuntime, c as ResolvedRulesetCandidate, d as RulesetResolverContext, f as RulesetResolverInput, g as lintOpenApi, h as loadRuleset, i as OpenApiLintArtifactWriteError, l as RulesetLoadError, m as loadResolvedRuleset, n as enforceThreshold, o as LoadResolvedRulesetOptions, p as defaultRulesetResolvers, r as shouldFail, s as LoadedRuleset, t as OpenApiLintThresholdError, u as RulesetResolver } from "../index-CyJXdIRT.mjs";
2
2
  export { LoadResolvedRulesetOptions, LoadedRuleset, OpenApiLintArtifactWriteError, OpenApiLintThresholdError, ResolvedRulesetCandidate, RulesetLoadError, RulesetResolver, RulesetResolverContext, RulesetResolverInput, createOpenApiLintRuntime, defaultRulesetResolvers, enforceThreshold, lintOpenApi, loadResolvedRuleset, loadRuleset, shouldFail };
@@ -38,6 +38,7 @@ type SpectralPluginOptions = {
38
38
  };
39
39
  dashboard?: false | {
40
40
  path?: string;
41
+ bearerToken?: string;
41
42
  };
42
43
  output?: {
43
44
  console?: boolean;
package/dist/index.d.mts CHANGED
@@ -1,4 +1,4 @@
1
- import { A as SpectralLogger, C as OpenApiLintRuntime, D as OpenApiLintSinkContext, E as OpenApiLintSink, M as StartupLintMode, O as PresetName, S as OpenApiLintArtifacts, T as OpenApiLintRuntimeStatus, _ as ArtifactWriteFailureMode, a as createOpenApiLintRuntime, b as LintRunSource, c as ResolvedRulesetCandidate, d as RulesetResolverContext, f as RulesetResolverInput, g as lintOpenApi, h as loadRuleset, i as OpenApiLintArtifactWriteError, j as SpectralPluginOptions, k as SeverityThreshold, l as RulesetLoadError, m as loadResolvedRuleset, n as enforceThreshold, o as LoadResolvedRulesetOptions, p as defaultRulesetResolvers, r as shouldFail, s as LoadedRuleset, t as OpenApiLintThresholdError, u as RulesetResolver, v as LintFinding, w as OpenApiLintRuntimeFailure, x as LintSeverity, y as LintRunResult } from "./index-Dx83hCT9.mjs";
1
+ import { A as SpectralLogger, C as OpenApiLintRuntime, D as OpenApiLintSinkContext, E as OpenApiLintSink, M as StartupLintMode, O as PresetName, S as OpenApiLintArtifacts, T as OpenApiLintRuntimeStatus, _ as ArtifactWriteFailureMode, a as createOpenApiLintRuntime, b as LintRunSource, c as ResolvedRulesetCandidate, d as RulesetResolverContext, f as RulesetResolverInput, g as lintOpenApi, h as loadRuleset, i as OpenApiLintArtifactWriteError, j as SpectralPluginOptions, k as SeverityThreshold, l as RulesetLoadError, m as loadResolvedRuleset, n as enforceThreshold, o as LoadResolvedRulesetOptions, p as defaultRulesetResolvers, r as shouldFail, s as LoadedRuleset, t as OpenApiLintThresholdError, u as RulesetResolver, v as LintFinding, w as OpenApiLintRuntimeFailure, x as LintSeverity, y as LintRunResult } from "./index-CyJXdIRT.mjs";
2
2
  import { RulesetDefinition } from "@stoplight/spectral-core";
3
3
  import { Elysia } from "elysia";
4
4
 
package/dist/index.mjs CHANGED
@@ -1,5 +1,11 @@
1
1
  import { a as enforceThreshold, c as strict, d as RulesetLoadError, f as defaultRulesetResolvers, g as lintOpenApi, h as recommended, i as OpenApiLintThresholdError, l as server, m as loadRuleset, n as createOpenApiLintRuntime, o as shouldFail, p as loadResolvedRuleset, r as resolveStartupMode, s as presets, t as OpenApiLintArtifactWriteError, u as resolveReporter } from "./core-BLJeXQ15.mjs";
2
2
  import { Elysia } from "elysia";
3
+ //#region \0text:/home/runner/work/elysia-spectral/elysia-spectral/packages/elysia-spectral/src/output/dashboard.client.js.txt
4
+ var dashboard_client_js_default = "export default \"(() => {\\n const THEME_KEY = 'elysia-spectral-theme';\\n const THEMES = ['astro', 'tron', '808'];\\n let stored = null;\\n try {\\n stored = localStorage.getItem(THEME_KEY);\\n } catch {}\\n const initial = THEMES.includes(stored) ? stored : 'astro';\\n document.documentElement.dataset.theme = initial;\\n\\n const themeSel = document.querySelector('[data-theme-switcher]');\\n if (themeSel) {\\n themeSel.value = initial;\\n themeSel.addEventListener('change', () => {\\n const next = THEMES.includes(themeSel.value) ? themeSel.value : 'astro';\\n document.documentElement.dataset.theme = next;\\n try {\\n localStorage.setItem(THEME_KEY, next);\\n } catch {}\\n });\\n }\\n\\n const rel = (iso) => {\\n const t = Date.parse(iso);\\n if (Number.isNaN(t)) return '';\\n const s = Math.round((Date.now() - t) / 1000);\\n if (s < 60) return s + 's ago';\\n if (s < 3600) return Math.round(s / 60) + 'm ago';\\n if (s < 86400) return Math.round(s / 3600) + 'h ago';\\n return Math.round(s / 86400) + 'd ago';\\n };\\n for (const el of document.querySelectorAll('[data-relative-time]')) {\\n el.textContent = rel(el.getAttribute('data-relative-time'));\\n }\\n\\n const rows = Array.from(document.querySelectorAll('[data-findings] tr'));\\n const search = document.querySelector('[data-search]');\\n const empty = document.querySelector('[data-empty-findings]');\\n const chips = Array.from(document.querySelectorAll('[data-filter]'));\\n let activeSeverity = 'all';\\n let query = '';\\n\\n const apply = () => {\\n let visible = 0;\\n for (const tr of rows) {\\n const sev = tr.getAttribute('data-severity');\\n const hay = tr.getAttribute('data-haystack') || '';\\n const sevOk = activeSeverity === 'all' || sev === activeSeverity;\\n const qOk = !query || hay.includes(query);\\n const show = sevOk && qOk;\\n tr.classList.toggle('is-hidden', !show);\\n if (show) visible += 1;\\n }\\n if (empty)\\n empty.classList.toggle('hidden', visible !== 0 || rows.length === 0);\\n };\\n\\n for (const chip of chips) {\\n const select = () => {\\n activeSeverity = chip.getAttribute('data-filter') || 'all';\\n for (const c of chips) c.classList.toggle('is-active', c === chip);\\n apply();\\n };\\n chip.addEventListener('click', select);\\n chip.addEventListener('keydown', (e) => {\\n if (e.key === 'Enter' || e.key === ' ') {\\n e.preventDefault();\\n select();\\n }\\n });\\n }\\n\\n if (search) {\\n search.addEventListener('input', () => {\\n query = search.value.trim().toLowerCase();\\n apply();\\n });\\n }\\n\\n for (const btn of document.querySelectorAll('[data-copy]')) {\\n btn.addEventListener('click', async () => {\\n const value = btn.getAttribute('data-copy') || '';\\n try {\\n await navigator.clipboard.writeText(value);\\n btn.classList.add('copied');\\n btn.textContent = 'copied';\\n setTimeout(() => {\\n btn.classList.remove('copied');\\n btn.textContent = 'copy';\\n }, 1200);\\n } catch {}\\n });\\n }\\n\\n document.addEventListener('keydown', (e) => {\\n if (\\n e.target &&\\n (e.target.tagName === 'INPUT' ||\\n e.target.tagName === 'TEXTAREA' ||\\n e.target.tagName === 'SELECT')\\n )\\n return;\\n if (e.key === 'r') {\\n const link = document.querySelector('[data-refresh]');\\n if (link) link.click();\\n }\\n if (e.key === '/' && search) {\\n e.preventDefault();\\n search.focus();\\n }\\n });\\n})();\\n\";";
5
+ //#endregion
6
+ //#region \0text:/home/runner/work/elysia-spectral/elysia-spectral/packages/elysia-spectral/src/output/dashboard.css.txt
7
+ var dashboard_css_default = "export default \":root {\\n color-scheme: dark;\\n --bg: #17191e;\\n --fg: #eef0f9;\\n --muted: #8a93a0;\\n --line: #262a33;\\n --surface: #1d2027;\\n --accent: #ad5dca;\\n --accent-soft: #2b7eca;\\n --pass: #23d18b;\\n --fail: #dc3657;\\n --warn: #ffc368;\\n --info: #54b9ff;\\n --hint: #545864;\\n --mono:\\n ui-monospace, SFMono-Regular, \\\"JetBrains Mono\\\", Menlo, Monaco, Consolas,\\n \\\"Liberation Mono\\\", \\\"Courier New\\\", monospace;\\n}\\n\\n[data-theme=\\\"tron\\\"] {\\n --bg: #06080d;\\n --fg: #e6f7ff;\\n --muted: #5d8aa3;\\n --line: #0e2c3d;\\n --surface: #0a1320;\\n --accent: #38f3ff;\\n --accent-soft: #1ea7ff;\\n --pass: #7de8ff;\\n --fail: #ff3c5f;\\n --warn: #a8f5ff;\\n --info: #73d8ff;\\n --hint: #273746;\\n}\\n\\n[data-theme=\\\"808\\\"] {\\n --bg: #202020;\\n --fg: #ffffff;\\n --muted: #9a958a;\\n --line: #2a2a2a;\\n --surface: #262626;\\n --accent: #f8a125;\\n --accent-soft: #e72e2e;\\n --pass: #f1f827;\\n --fail: #e72e2e;\\n --warn: #f8a125;\\n --info: #f1f827;\\n --hint: #404040;\\n}\\n\\n* {\\n box-sizing: border-box;\\n}\\n\\nbody {\\n margin: 0;\\n font-family: var(--mono);\\n font-feature-settings:\\n \\\"calt\\\" 0,\\n \\\"liga\\\" 0,\\n \\\"ss01\\\";\\n background: var(--bg);\\n color: var(--fg);\\n font-size: 13px;\\n line-height: 1.5;\\n}\\n\\nheader {\\n position: sticky;\\n top: 0;\\n z-index: 10;\\n display: flex;\\n align-items: center;\\n justify-content: space-between;\\n padding: 16px 24px;\\n border-bottom: 1px solid var(--line);\\n background: color-mix(in srgb, var(--bg) 88%, transparent);\\n backdrop-filter: blur(8px);\\n overflow: hidden;\\n}\\n\\nheader::before {\\n content: \\\"\\\";\\n position: absolute;\\n inset: 0;\\n z-index: 0;\\n background:\\n radial-gradient(900px 220px at 12% -30%, var(--accent), transparent 60%),\\n radial-gradient(\\n 700px 200px at 88% -20%,\\n var(--accent-soft),\\n transparent 65%\\n );\\n opacity: 0.45;\\n pointer-events: none;\\n}\\n\\nheader > * {\\n position: relative;\\n z-index: 1;\\n}\\n\\n.brand {\\n display: flex;\\n align-items: center;\\n gap: 10px;\\n}\\n\\n.dot {\\n width: 8px;\\n height: 8px;\\n border-radius: 50%;\\n background: var(--accent);\\n box-shadow:\\n 0 0 0 3px color-mix(in srgb, var(--accent) 25%, transparent),\\n 0 0 18px color-mix(in srgb, var(--accent) 60%, transparent);\\n}\\n\\nh1 {\\n margin: 0;\\n font-size: 13px;\\n font-weight: 600;\\n letter-spacing: 0.02em;\\n text-transform: uppercase;\\n}\\n\\n.actions {\\n display: flex;\\n align-items: center;\\n gap: 10px;\\n}\\n\\n.visually-hidden {\\n position: absolute;\\n width: 1px;\\n height: 1px;\\n overflow: hidden;\\n clip: rect(0 0 0 0);\\n}\\n\\n.theme-switch {\\n display: inline-flex;\\n align-items: center;\\n gap: 6px;\\n}\\n\\n.theme-label {\\n font-size: 10px;\\n text-transform: uppercase;\\n letter-spacing: 0.08em;\\n color: var(--muted);\\n}\\n\\n.theme-switch select {\\n font-family: var(--mono);\\n font-size: 11px;\\n padding: 5px 24px 5px 10px;\\n border: 1px solid var(--line);\\n border-radius: 6px;\\n background: var(--surface)\\n url(\\\"data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='10' height='6' viewBox='0 0 10 6'><path fill='none' stroke='%238a93a0' stroke-width='1.4' d='M1 1l4 4 4-4'/></svg>\\\")\\n no-repeat right 8px center;\\n color: var(--fg);\\n -webkit-appearance: none;\\n appearance: none;\\n cursor: pointer;\\n}\\n.theme-switch select:hover {\\n border-color: var(--muted);\\n}\\n.theme-switch select:focus {\\n outline: none;\\n border-color: var(--accent);\\n}\\n\\nkbd {\\n font-family: var(--mono);\\n font-size: 11px;\\n padding: 2px 6px;\\n border: 1px solid var(--line);\\n border-bottom-width: 2px;\\n border-radius: 4px;\\n color: var(--muted);\\n background: var(--surface);\\n}\\n\\n.refresh {\\n color: var(--fg);\\n text-decoration: none;\\n padding: 6px 12px;\\n border: 1px solid var(--line);\\n border-radius: 6px;\\n font-size: 12px;\\n font-family: var(--mono);\\n background: var(--surface);\\n transition:\\n border-color 0.15s,\\n transform 0.05s;\\n}\\n.refresh:hover {\\n border-color: var(--accent);\\n}\\n.refresh:active {\\n transform: translateY(1px);\\n}\\n\\nmain {\\n padding: 24px;\\n max-width: 1100px;\\n margin: 0 auto;\\n}\\n\\n.banner {\\n padding: 12px 16px;\\n border-radius: 8px;\\n margin-bottom: 16px;\\n font-size: 13px;\\n}\\n.banner-pass {\\n background: color-mix(in srgb, var(--pass) 14%, transparent);\\n border: 1px solid color-mix(in srgb, var(--pass) 60%, var(--line));\\n}\\n.banner-fail {\\n background: color-mix(in srgb, var(--fail) 14%, transparent);\\n border: 1px solid color-mix(in srgb, var(--fail) 60%, var(--line));\\n}\\n.tagline {\\n display: block;\\n margin-top: 6px;\\n font-weight: 700;\\n letter-spacing: 0.12em;\\n color: var(--pass);\\n text-transform: uppercase;\\n font-size: 11px;\\n}\\n\\n.meta {\\n display: grid;\\n grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));\\n gap: 8px;\\n margin: 0 0 16px;\\n padding: 0;\\n}\\n.meta div {\\n background: var(--surface);\\n border: 1px solid var(--line);\\n border-radius: 6px;\\n padding: 8px 12px;\\n}\\n.meta dt {\\n color: var(--muted);\\n font-size: 10px;\\n text-transform: uppercase;\\n letter-spacing: 0.06em;\\n margin-bottom: 4px;\\n}\\n.meta dd {\\n margin: 0;\\n font-size: 12px;\\n}\\n.muted-line {\\n display: block;\\n color: var(--muted);\\n font-size: 11px;\\n margin-top: 2px;\\n}\\n\\n.summary {\\n list-style: none;\\n padding: 0;\\n margin: 0 0 24px;\\n display: flex;\\n gap: 6px;\\n flex-wrap: wrap;\\n}\\n.summary li {\\n padding: 6px 12px;\\n border: 1px solid var(--line);\\n border-radius: 999px;\\n font-size: 12px;\\n color: var(--muted);\\n cursor: pointer;\\n user-select: none;\\n transition:\\n border-color 0.12s,\\n background 0.12s;\\n}\\n.summary li:hover {\\n border-color: var(--muted);\\n}\\n.summary li:focus {\\n outline: 2px solid color-mix(in srgb, var(--accent) 60%, transparent);\\n outline-offset: 2px;\\n}\\n.summary li.is-active {\\n background: color-mix(in srgb, var(--accent) 14%, transparent);\\n border-color: var(--accent);\\n color: var(--fg);\\n}\\n.summary li span {\\n color: var(--fg);\\n font-weight: 600;\\n margin-right: 6px;\\n}\\n.summary .sev-error span {\\n color: var(--fail);\\n}\\n.summary .sev-warn span {\\n color: var(--warn);\\n}\\n.summary .sev-info span {\\n color: var(--info);\\n}\\n\\nsection {\\n margin-top: 24px;\\n}\\nh2 {\\n font-size: 11px;\\n text-transform: uppercase;\\n letter-spacing: 0.06em;\\n color: var(--muted);\\n margin: 0 0 12px;\\n font-weight: 600;\\n}\\n\\n.findings-head {\\n display: flex;\\n align-items: center;\\n justify-content: space-between;\\n gap: 12px;\\n margin-bottom: 8px;\\n}\\n.findings-head h2 {\\n margin: 0;\\n}\\n\\n[data-search] {\\n font-family: var(--mono);\\n font-size: 12px;\\n padding: 6px 10px;\\n min-width: 240px;\\n background: var(--surface);\\n color: var(--fg);\\n border: 1px solid var(--line);\\n border-radius: 6px;\\n}\\n[data-search]:focus {\\n outline: none;\\n border-color: var(--accent);\\n}\\n\\n.artifacts {\\n list-style: none;\\n padding: 0;\\n margin: 0;\\n}\\n.artifacts li {\\n display: flex;\\n align-items: center;\\n gap: 12px;\\n padding: 6px 0;\\n border-bottom: 1px solid var(--line);\\n font-size: 12px;\\n}\\n.artifacts code {\\n color: var(--muted);\\n min-width: 160px;\\n}\\n.artifacts .path {\\n flex: 1;\\n word-break: break-all;\\n}\\n\\n.copy {\\n font-family: var(--mono);\\n font-size: 11px;\\n padding: 2px 8px;\\n border: 1px solid var(--line);\\n background: var(--surface);\\n color: var(--muted);\\n border-radius: 4px;\\n cursor: pointer;\\n}\\n.copy:hover {\\n color: var(--fg);\\n border-color: var(--muted);\\n}\\n.copy.copied {\\n color: var(--pass);\\n border-color: var(--pass);\\n}\\n\\ntable {\\n width: 100%;\\n border-collapse: collapse;\\n font-size: 12px;\\n}\\nth,\\ntd {\\n text-align: left;\\n padding: 8px 12px;\\n border-bottom: 1px solid var(--line);\\n vertical-align: top;\\n}\\nth {\\n color: var(--muted);\\n font-weight: 500;\\n font-size: 10px;\\n text-transform: uppercase;\\n letter-spacing: 0.06em;\\n}\\ntbody tr:hover {\\n background: color-mix(in srgb, var(--fg) 4%, transparent);\\n}\\ntr.is-hidden {\\n display: none;\\n}\\n\\n.badge {\\n display: inline-block;\\n padding: 2px 8px;\\n border-radius: 4px;\\n font-size: 10px;\\n text-transform: uppercase;\\n letter-spacing: 0.06em;\\n font-family: var(--mono);\\n}\\n.sev-error .badge {\\n background: color-mix(in srgb, var(--fail) 22%, transparent);\\n color: var(--fail);\\n}\\n.sev-warn .badge {\\n background: color-mix(in srgb, var(--warn) 22%, transparent);\\n color: var(--warn);\\n}\\n.sev-info .badge {\\n background: color-mix(in srgb, var(--info) 22%, transparent);\\n color: var(--info);\\n}\\n.sev-hint .badge {\\n background: color-mix(in srgb, var(--hint) 22%, transparent);\\n color: var(--hint);\\n}\\n\\n.recommendation {\\n margin: 4px 0 0;\\n color: var(--muted);\\n font-size: 11px;\\n}\\n.pointer {\\n display: block;\\n margin-top: 4px;\\n color: var(--muted);\\n font-size: 11px;\\n}\\n.empty {\\n color: var(--muted);\\n font-size: 12px;\\n}\\n.hidden {\\n display: none;\\n}\\n\";";
8
+ //#endregion
3
9
  //#region src/output/dashboard.ts
4
10
  const renderDashboard = (input) => {
5
11
  const { result, threshold, cached, error, refreshPath } = input;
@@ -10,7 +16,7 @@ const renderDashboard = (input) => {
10
16
  <meta charset="utf-8" />
11
17
  <meta name="viewport" content="width=device-width,initial-scale=1" />
12
18
  <title>Elysia Spectral Lint Dashboard</title>
13
- <style>${styles}</style>
19
+ <style>${dashboard_css_default}</style>
14
20
  </head>
15
21
  <body>
16
22
  <header>
@@ -19,12 +25,20 @@ const renderDashboard = (input) => {
19
25
  <h1>Elysia Spectral Lint</h1>
20
26
  </div>
21
27
  <div class="actions">
28
+ <label class="theme-switch" title="Switch dashboard theme">
29
+ <span class="theme-label">Theme</span>
30
+ <select data-theme-switcher aria-label="Dashboard theme">
31
+ <option value="astro">Astro Houston</option>
32
+ <option value="tron">Tron Legacy</option>
33
+ <option value="808">Detroit 808</option>
34
+ </select>
35
+ </label>
22
36
  <kbd title="Press r to re-run">r</kbd>
23
37
  <a class="refresh" href="${`${escapeAttr(refreshPath)}?fresh=1`}" data-refresh>Re-run</a>
24
38
  </div>
25
39
  </header>
26
40
  <main>${body}</main>
27
- <script>${script}<\/script>
41
+ <script>${dashboard_client_js_default}<\/script>
28
42
  </body>
29
43
  </html>`;
30
44
  };
@@ -82,130 +96,6 @@ const renderFindings = (findings) => {
82
96
  };
83
97
  const escapeText = (value) => value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
84
98
  const escapeAttr = escapeText;
85
- const styles = `
86
- :root { color-scheme: light dark; --bg:#0b0d10; --fg:#e6e6e6; --muted:#8a93a0; --line:#1f242b; --surface:#11151a; --pass:#3ddc97; --fail:#ff5d5d; --warn:#f5a623; --info:#5dade2; --hint:#bdc3c7; --mono: ui-monospace, SFMono-Regular, "JetBrains Mono", Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; }
87
- @media (prefers-color-scheme: light) { :root { --bg:#fafafa; --fg:#1a1a1a; --muted:#6b7380; --line:#e3e6eb; --surface:#ffffff; } }
88
- * { box-sizing: border-box; }
89
- body { margin:0; font-family: var(--mono); font-feature-settings: "calt" 0, "liga" 0, "ss01"; background:var(--bg); color:var(--fg); font-size:13px; line-height:1.5; }
90
- header { position:sticky; top:0; z-index:10; display:flex; align-items:center; justify-content:space-between; padding:12px 24px; border-bottom:1px solid var(--line); background:color-mix(in srgb, var(--bg) 92%, transparent); backdrop-filter: blur(8px); }
91
- .brand { display:flex; align-items:center; gap:10px; }
92
- .dot { width:8px; height:8px; border-radius:50%; background:var(--pass); box-shadow:0 0 0 3px color-mix(in srgb, var(--pass) 25%, transparent); }
93
- h1 { margin:0; font-size:13px; font-weight:600; letter-spacing:.02em; text-transform:uppercase; }
94
- .actions { display:flex; align-items:center; gap:10px; }
95
- kbd { font-family:var(--mono); font-size:11px; padding:2px 6px; border:1px solid var(--line); border-bottom-width:2px; border-radius:4px; color:var(--muted); background:var(--surface); }
96
- .refresh { color:var(--fg); text-decoration:none; padding:6px 12px; border:1px solid var(--line); border-radius:6px; font-size:12px; font-family:var(--mono); background:var(--surface); transition:border-color .15s, transform .05s; }
97
- .refresh:hover { border-color:var(--muted); }
98
- .refresh:active { transform: translateY(1px); }
99
- main { padding:24px; max-width:1100px; margin:0 auto; }
100
- .banner { padding:12px 16px; border-radius:8px; margin-bottom:16px; font-size:13px; }
101
- .banner-pass { background: color-mix(in srgb, var(--pass) 14%, transparent); border:1px solid color-mix(in srgb, var(--pass) 60%, var(--line)); }
102
- .banner-fail { background: color-mix(in srgb, var(--fail) 14%, transparent); border:1px solid color-mix(in srgb, var(--fail) 60%, var(--line)); }
103
- .tagline { display:block; margin-top:6px; font-weight:700; letter-spacing:.12em; color:var(--pass); text-transform:uppercase; font-size:11px; }
104
- .meta { display:grid; grid-template-columns: repeat(auto-fit, minmax(180px,1fr)); gap:8px; margin:0 0 16px; padding:0; }
105
- .meta div { background:var(--surface); border:1px solid var(--line); border-radius:6px; padding:8px 12px; }
106
- .meta dt { color:var(--muted); font-size:10px; text-transform:uppercase; letter-spacing:.06em; margin-bottom:4px; }
107
- .meta dd { margin:0; font-size:12px; }
108
- .muted-line { display:block; color:var(--muted); font-size:11px; margin-top:2px; }
109
- .summary { list-style:none; padding:0; margin:0 0 24px; display:flex; gap:6px; flex-wrap:wrap; }
110
- .summary li { padding:6px 12px; border:1px solid var(--line); border-radius:999px; font-size:12px; color:var(--muted); cursor:pointer; user-select:none; transition:border-color .12s, background .12s; }
111
- .summary li:hover { border-color:var(--muted); }
112
- .summary li:focus { outline:2px solid color-mix(in srgb, var(--info) 60%, transparent); outline-offset:2px; }
113
- .summary li.is-active { background: color-mix(in srgb, var(--fg) 8%, transparent); border-color:var(--muted); color:var(--fg); }
114
- .summary li span { color:var(--fg); font-weight:600; margin-right:6px; }
115
- .summary .sev-error span { color:var(--fail); }
116
- .summary .sev-warn span { color:var(--warn); }
117
- .summary .sev-info span { color:var(--info); }
118
- section { margin-top:24px; }
119
- h2 { font-size:11px; text-transform:uppercase; letter-spacing:.06em; color:var(--muted); margin:0 0 12px; font-weight:600; }
120
- .findings-head { display:flex; align-items:center; justify-content:space-between; gap:12px; margin-bottom:8px; }
121
- .findings-head h2 { margin:0; }
122
- [data-search] { font-family:var(--mono); font-size:12px; padding:6px 10px; min-width:240px; background:var(--surface); color:var(--fg); border:1px solid var(--line); border-radius:6px; }
123
- [data-search]:focus { outline:none; border-color:var(--muted); }
124
- .artifacts { list-style:none; padding:0; margin:0; }
125
- .artifacts li { display:flex; align-items:center; gap:12px; padding:6px 0; border-bottom:1px solid var(--line); font-size:12px; }
126
- .artifacts code { color:var(--muted); min-width:160px; }
127
- .artifacts .path { flex:1; word-break:break-all; }
128
- .copy { font-family:var(--mono); font-size:11px; padding:2px 8px; border:1px solid var(--line); background:var(--surface); color:var(--muted); border-radius:4px; cursor:pointer; }
129
- .copy:hover { color:var(--fg); border-color:var(--muted); }
130
- .copy.copied { color:var(--pass); border-color:var(--pass); }
131
- table { width:100%; border-collapse:collapse; font-size:12px; }
132
- th, td { text-align:left; padding:8px 12px; border-bottom:1px solid var(--line); vertical-align:top; }
133
- th { color:var(--muted); font-weight:500; font-size:10px; text-transform:uppercase; letter-spacing:.06em; }
134
- tbody tr:hover { background: color-mix(in srgb, var(--fg) 4%, transparent); }
135
- tr.is-hidden { display:none; }
136
- .badge { display:inline-block; padding:2px 8px; border-radius:4px; font-size:10px; text-transform:uppercase; letter-spacing:.06em; font-family:var(--mono); }
137
- .sev-error .badge { background: color-mix(in srgb, var(--fail) 22%, transparent); color:var(--fail); }
138
- .sev-warn .badge { background: color-mix(in srgb, var(--warn) 22%, transparent); color:var(--warn); }
139
- .sev-info .badge { background: color-mix(in srgb, var(--info) 22%, transparent); color:var(--info); }
140
- .sev-hint .badge { background: color-mix(in srgb, var(--hint) 22%, transparent); color:var(--hint); }
141
- .recommendation { margin:4px 0 0; color:var(--muted); font-size:11px; }
142
- .pointer { display:block; margin-top:4px; color:var(--muted); font-size:11px; }
143
- .empty { color:var(--muted); font-size:12px; }
144
- .hidden { display:none; }
145
- `;
146
- const script = `
147
- (() => {
148
- const rel = (iso) => {
149
- const t = Date.parse(iso); if (Number.isNaN(t)) return '';
150
- const s = Math.round((Date.now() - t) / 1000);
151
- if (s < 60) return s + 's ago';
152
- if (s < 3600) return Math.round(s/60) + 'm ago';
153
- if (s < 86400) return Math.round(s/3600) + 'h ago';
154
- return Math.round(s/86400) + 'd ago';
155
- };
156
- for (const el of document.querySelectorAll('[data-relative-time]')) {
157
- el.textContent = rel(el.getAttribute('data-relative-time'));
158
- }
159
-
160
- const rows = Array.from(document.querySelectorAll('[data-findings] tr'));
161
- const search = document.querySelector('[data-search]');
162
- const empty = document.querySelector('[data-empty-findings]');
163
- const chips = Array.from(document.querySelectorAll('[data-filter]'));
164
- let activeSeverity = 'all';
165
- let query = '';
166
-
167
- const apply = () => {
168
- let visible = 0;
169
- for (const tr of rows) {
170
- const sev = tr.getAttribute('data-severity');
171
- const hay = tr.getAttribute('data-haystack') || '';
172
- const sevOk = activeSeverity === 'all' || sev === activeSeverity;
173
- const qOk = !query || hay.includes(query);
174
- const show = sevOk && qOk;
175
- tr.classList.toggle('is-hidden', !show);
176
- if (show) visible += 1;
177
- }
178
- if (empty) empty.classList.toggle('hidden', visible !== 0 || rows.length === 0);
179
- };
180
-
181
- for (const chip of chips) {
182
- const select = () => {
183
- activeSeverity = chip.getAttribute('data-filter') || 'all';
184
- for (const c of chips) c.classList.toggle('is-active', c === chip);
185
- apply();
186
- };
187
- chip.addEventListener('click', select);
188
- chip.addEventListener('keydown', (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); select(); } });
189
- }
190
-
191
- if (search) {
192
- search.addEventListener('input', () => { query = search.value.trim().toLowerCase(); apply(); });
193
- }
194
-
195
- for (const btn of document.querySelectorAll('[data-copy]')) {
196
- btn.addEventListener('click', async () => {
197
- const value = btn.getAttribute('data-copy') || '';
198
- try { await navigator.clipboard.writeText(value); btn.classList.add('copied'); btn.textContent = 'copied'; setTimeout(() => { btn.classList.remove('copied'); btn.textContent = 'copy'; }, 1200); } catch {}
199
- });
200
- }
201
-
202
- document.addEventListener('keydown', (e) => {
203
- if (e.target && (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA')) return;
204
- if (e.key === 'r') { const link = document.querySelector('[data-refresh]'); if (link) link.click(); }
205
- if (e.key === '/' && search) { e.preventDefault(); search.focus(); }
206
- });
207
- })();
208
- `;
209
99
  //#endregion
210
100
  //#region src/plugin.ts
211
101
  const spectralPlugin = (options = {}) => {
@@ -288,7 +178,17 @@ const spectralPlugin = (options = {}) => {
288
178
  }
289
179
  if (options.dashboard) {
290
180
  const dashboardPath = options.dashboard.path ?? "/__openapi/dashboard";
181
+ const bearerToken = options.dashboard.bearerToken;
291
182
  plugin = plugin.get(dashboardPath, async ({ request, set }) => {
183
+ if (bearerToken) {
184
+ const header = request.headers.get("authorization") ?? "";
185
+ if ((header.startsWith("Bearer ") ? header.slice(7) : "") !== bearerToken) {
186
+ set.status = 401;
187
+ set.headers["www-authenticate"] = "Bearer realm=\"elysia-spectral\"";
188
+ set.headers["content-type"] = "text/plain; charset=utf-8";
189
+ return "Unauthorized";
190
+ }
191
+ }
292
192
  const fresh = new URL(request.url).searchParams.get("fresh") === "1";
293
193
  const threshold = options.failOn ?? "error";
294
194
  const currentApp = hostAppRef.current;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@opsydyn/elysia-spectral",
3
- "version": "1.1.1",
3
+ "version": "1.3.0",
4
4
  "description": "Thin Elysia plugin that lints generated OpenAPI documents with Spectral.",
5
5
  "packageManager": "bun@1.3.11",
6
6
  "publishConfig": {