@opsydyn/elysia-spectral 0.5.2 → 1.1.1

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,36 @@
1
1
  # Changelog
2
2
 
3
+ ## [1.1.1](https://github.com/opsydyn/elysia-spectral/compare/v1.1.0...v1.1.1) (2026-04-28)
4
+
5
+
6
+ ### Bug Fixes
7
+
8
+ * trigger release pipeline ([50a07c1](https://github.com/opsydyn/elysia-spectral/commit/50a07c172eac778355d66c942affe9dcccc048b7))
9
+
10
+ ## [1.1.0](https://github.com/opsydyn/elysia-spectral/compare/v1.0.0...v1.1.0) (2026-04-28)
11
+
12
+
13
+ ### Features
14
+
15
+ * add HTML dashboard endpoint ([beba3cc](https://github.com/opsydyn/elysia-spectral/commit/beba3cc529960c443cb536dd3f4ce33ecb589cb8))
16
+ * redesign lint dashboard with monospace UI and interactive filters ([23ab4bb](https://github.com/opsydyn/elysia-spectral/commit/23ab4bbfde3b55074101256814f10daeaae0097b))
17
+
18
+
19
+ ### Bug Fixes
20
+
21
+ * stamp result.durationMs before threshold enforcement ([cafcf46](https://github.com/opsydyn/elysia-spectral/commit/cafcf4635bf72b0eb9848352901ec5725eca9501))
22
+
23
+ ## [1.0.0](https://github.com/opsydyn/elysia-spectral/compare/v0.5.2...v1.0.0) (2026-04-15)
24
+
25
+
26
+ ### ⚠ BREAKING CHANGES
27
+
28
+ * result.artifacts paths are now relative (e.g. "./artifacts/openapi-lint.json") rather than absolute. Callers that used the path directly to open the file should resolve it with path.resolve().
29
+
30
+ ### Features
31
+
32
+ * add durationMs, failOn to LintRunResult; relativise artifact paths ([421a20f](https://github.com/opsydyn/elysia-spectral/commit/421a20f6bf655432bceff8369c63bf4762edbc24))
33
+
3
34
  ## [0.5.2](https://github.com/opsydyn/elysia-spectral/compare/v0.5.1...v0.5.2) (2026-04-15)
4
35
 
5
36
 
package/README.md CHANGED
@@ -63,6 +63,35 @@ Current package scope:
63
63
  - reusable runtime for CI and tests
64
64
  - opt-in healthcheck endpoint for cached and fresh runs
65
65
 
66
+ ## Data flow
67
+
68
+ ```mermaid
69
+ flowchart TD
70
+ A["Elysia routes\n(TypeScript + t.Object schemas)"]
71
+ B["@elysiajs/openapi\ngenerates OpenAPI spec at runtime"]
72
+ C["PublicSpecProvider\nfetches /openapi/json"]
73
+ D["lintOpenApi\nSpectral rules engine"]
74
+ E["LintRunResult\n{ ok, source, summary, findings }"]
75
+
76
+ F["Console output"]
77
+ G["JSON report\nopenapi-lint.json"]
78
+ H["OpenAPI snapshot\n*.open-api.json"]
79
+ I["SARIF\nGitHub code scanning"]
80
+ J["JUnit\nCI test reporters"]
81
+ K["Bruno collection\n.yml / .json"]
82
+
83
+ A -->|"route schemas are\nthe source of truth"| B
84
+ B -->|"spec JSON"| C
85
+ C -->|"spec JSON"| D
86
+ D -->|"findings + summary"| E
87
+ E --> F
88
+ E --> G
89
+ E --> H
90
+ E --> I
91
+ E --> J
92
+ E --> K
93
+ ```
94
+
66
95
  ## Tutorial
67
96
 
68
97
  ### Add OpenAPI linting to an Elysia app
@@ -153,6 +182,10 @@ bun run src/index.ts
153
182
  - `./artifacts/openapi-lint.json` contains the full lint result
154
183
  - `./<package-name>.open-api.json` contains the generated OpenAPI snapshot
155
184
 
185
+ A passing run:
186
+
187
+ ![OpenAPI lint pass banner](ship-it.png)
188
+
156
189
  If startup fails, the terminal output includes:
157
190
 
158
191
  - the failing rule code
@@ -160,6 +193,8 @@ If startup fails, the terminal output includes:
160
193
  - a fix hint when one is known
161
194
  - a spec reference in `open-api.json#/json/pointer` form
162
195
 
196
+ ![OpenAPI lint error output](errrors.png)
197
+
163
198
  ### Choose a preset
164
199
 
165
200
  Three first-party presets are available. Import them directly or set `preset` in the plugin options.
@@ -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-11HnbLDN.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-Dx83hCT9.mjs";
2
2
  export { LoadResolvedRulesetOptions, LoadedRuleset, OpenApiLintArtifactWriteError, OpenApiLintThresholdError, ResolvedRulesetCandidate, RulesetLoadError, RulesetResolver, RulesetResolverContext, RulesetResolverInput, createOpenApiLintRuntime, defaultRulesetResolvers, enforceThreshold, lintOpenApi, loadResolvedRuleset, loadRuleset, shouldFail };
@@ -1,2 +1,2 @@
1
- import { a as enforceThreshold, d as RulesetLoadError, f as defaultRulesetResolvers, g as lintOpenApi, i as OpenApiLintThresholdError, m as loadRuleset, n as createOpenApiLintRuntime, o as shouldFail, p as loadResolvedRuleset, t as OpenApiLintArtifactWriteError } from "../core-DTKNy6TU.mjs";
1
+ import { a as enforceThreshold, d as RulesetLoadError, f as defaultRulesetResolvers, g as lintOpenApi, i as OpenApiLintThresholdError, m as loadRuleset, n as createOpenApiLintRuntime, o as shouldFail, p as loadResolvedRuleset, t as OpenApiLintArtifactWriteError } from "../core-BLJeXQ15.mjs";
2
2
  export { OpenApiLintArtifactWriteError, OpenApiLintThresholdError, RulesetLoadError, createOpenApiLintRuntime, defaultRulesetResolvers, enforceThreshold, lintOpenApi, loadResolvedRuleset, loadRuleset, shouldFail };
@@ -55,6 +55,8 @@ const normalizeFindings = (diagnostics, spec) => {
55
55
  ok: summary.error === 0,
56
56
  generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
57
57
  source: "manual",
58
+ failOn: "error",
59
+ durationMs: null,
58
60
  summary,
59
61
  findings
60
62
  };
@@ -1198,19 +1200,21 @@ const createOpenApiLintRuntime = (options = {}) => {
1198
1200
  else if (loadedRuleset.source?.path) reporter.ruleset(`OpenAPI lint loaded ruleset ${loadedRuleset.source.path}.`);
1199
1201
  const result = await lintOpenApi(spec, loadedRuleset.ruleset);
1200
1202
  result.source = source;
1201
- result.ok = !shouldFail(result, options.failOn ?? "error");
1203
+ result.failOn = options.failOn ?? "error";
1204
+ result.ok = !shouldFail(result, result.failOn);
1202
1205
  await writeOutputSinks(result, spec, options, artifactWriteFailureMode);
1203
1206
  runtime.latest = result;
1207
+ finalizeRuntimeRun(runtime, startedAt);
1208
+ result.durationMs = runtime.durationMs;
1204
1209
  reporter.complete("OpenAPI lint completed.");
1205
1210
  enforceThreshold(result, options.failOn ?? "error");
1206
1211
  runtime.status = "passed";
1207
1212
  runtime.lastSuccess = result;
1208
- finalizeRuntimeRun(runtime, startedAt);
1209
1213
  return result;
1210
1214
  } catch (error) {
1211
1215
  runtime.status = "failed";
1212
1216
  runtime.lastFailure = toRuntimeFailure(error);
1213
- finalizeRuntimeRun(runtime, startedAt);
1217
+ if (runtime.durationMs === null) finalizeRuntimeRun(runtime, startedAt);
1214
1218
  throw error;
1215
1219
  }
1216
1220
  })();
@@ -1264,9 +1268,18 @@ const writeOutputSinks = async (result, spec, options, artifactWriteFailureMode)
1264
1268
  throw error;
1265
1269
  }
1266
1270
  };
1271
+ const relativiseArtifacts = (artifacts) => {
1272
+ const cwd = process.cwd();
1273
+ const result = {};
1274
+ for (const [key, value] of Object.entries(artifacts)) if (typeof value === "string" && path.isAbsolute(value)) {
1275
+ const rel = path.relative(cwd, value);
1276
+ result[key] = rel.startsWith(".") ? rel : `./${rel}`;
1277
+ } else result[key] = value;
1278
+ return result;
1279
+ };
1267
1280
  const mergeArtifacts = (current, next) => ({
1268
1281
  ...current,
1269
- ...next
1282
+ ...relativiseArtifacts(next)
1270
1283
  });
1271
1284
  const resolveStartupMode = (options = {}) => {
1272
1285
  if (options.startup?.mode) return options.startup.mode;
@@ -36,6 +36,9 @@ type SpectralPluginOptions = {
36
36
  healthcheck?: false | {
37
37
  path?: string;
38
38
  };
39
+ dashboard?: false | {
40
+ path?: string;
41
+ };
39
42
  output?: {
40
43
  console?: boolean;
41
44
  jsonReportPath?: string;
@@ -85,6 +88,8 @@ type LintRunResult = {
85
88
  ok: boolean;
86
89
  generatedAt: string;
87
90
  source: LintRunSource;
91
+ failOn: SeverityThreshold;
92
+ durationMs: number | null;
88
93
  summary: {
89
94
  error: number;
90
95
  warn: number;
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-11HnbLDN.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-Dx83hCT9.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,212 @@
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-DTKNy6TU.mjs";
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 src/output/dashboard.ts
4
+ const renderDashboard = (input) => {
5
+ const { result, threshold, cached, error, refreshPath } = input;
6
+ const body = error && !result ? renderError(error) : result ? renderReport(result, threshold, cached) : renderEmpty();
7
+ return `<!doctype html>
8
+ <html lang="en">
9
+ <head>
10
+ <meta charset="utf-8" />
11
+ <meta name="viewport" content="width=device-width,initial-scale=1" />
12
+ <title>Elysia Spectral Lint Dashboard</title>
13
+ <style>${styles}</style>
14
+ </head>
15
+ <body>
16
+ <header>
17
+ <div class="brand">
18
+ <span class="dot" aria-hidden="true"></span>
19
+ <h1>Elysia Spectral Lint</h1>
20
+ </div>
21
+ <div class="actions">
22
+ <kbd title="Press r to re-run">r</kbd>
23
+ <a class="refresh" href="${`${escapeAttr(refreshPath)}?fresh=1`}" data-refresh>Re-run</a>
24
+ </div>
25
+ </header>
26
+ <main>${body}</main>
27
+ <script>${script}<\/script>
28
+ </body>
29
+ </html>`;
30
+ };
31
+ const renderEmpty = () => `<p class="empty">No lint result yet. <a href="?fresh=1">Run now</a>.</p>`;
32
+ const renderError = (message) => `<div class="banner banner-fail"><strong>Lint runtime error</strong><p>${escapeText(message)}</p></div>`;
33
+ const renderReport = (result, threshold, cached) => {
34
+ return `${result.ok ? `<div class="banner banner-pass"><strong>Pass</strong> at threshold "${escapeText(threshold)}"<span class="tagline">SPEC IS TIGHT, SHIP IT RIGHT</span></div>` : `<div class="banner banner-fail"><strong>Fail</strong> at threshold "${escapeText(threshold)}"</div>`}${`
35
+ <dl class="meta">
36
+ <div><dt>Generated</dt><dd>${escapeText(result.generatedAt)}<span class="muted-line" data-relative-time="${escapeAttr(result.generatedAt)}"></span></dd></div>
37
+ <div><dt>Source</dt><dd>${escapeText(result.source)}</dd></div>
38
+ <div><dt>Duration</dt><dd>${result.durationMs ?? "—"} ms</dd></div>
39
+ <div><dt>Cached</dt><dd>${cached ? "yes" : "no"}</dd></div>
40
+ </dl>`}${`
41
+ <ul class="summary" data-filter-bar>
42
+ <li class="sev-all is-active" data-filter="all" tabindex="0"><span>${result.summary.total}</span> total</li>
43
+ <li class="sev-error" data-filter="error" tabindex="0"><span>${result.summary.error}</span> error</li>
44
+ <li class="sev-warn" data-filter="warn" tabindex="0"><span>${result.summary.warn}</span> warn</li>
45
+ <li class="sev-info" data-filter="info" tabindex="0"><span>${result.summary.info}</span> info</li>
46
+ <li class="sev-hint" data-filter="hint" tabindex="0"><span>${result.summary.hint}</span> hint</li>
47
+ </ul>`}${renderArtifacts(result.artifacts)}${renderFindings(result.findings)}`;
48
+ };
49
+ const renderArtifacts = (artifacts) => {
50
+ if (!artifacts || Object.keys(artifacts).length === 0) return "";
51
+ return `<section><h2>Artifacts</h2><ul class="artifacts">${Object.entries(artifacts).map(([key, value]) => `<li><code>${escapeText(key)}</code><span class="path">${escapeText(String(value))}</span><button class="copy" type="button" data-copy="${escapeAttr(String(value))}" title="Copy path">copy</button></li>`).join("")}</ul></section>`;
52
+ };
53
+ const renderFindings = (findings) => {
54
+ if (findings.length === 0) return `<section><h2>Findings</h2><p class="empty">No findings.</p></section>`;
55
+ const rows = findings.map((finding) => {
56
+ const operation = finding.operation?.method && finding.operation?.path ? `${finding.operation.method.toUpperCase()} ${finding.operation.path}` : "—";
57
+ const recommendation = finding.recommendation ? `<p class="recommendation">${escapeText(finding.recommendation)}</p>` : "";
58
+ const haystack = [
59
+ finding.code,
60
+ finding.message,
61
+ operation,
62
+ finding.documentPointer ?? ""
63
+ ].join(" ").toLowerCase();
64
+ return `<tr class="sev-${finding.severity}" data-severity="${escapeAttr(finding.severity)}" data-haystack="${escapeAttr(haystack)}">
65
+ <td><span class="badge">${finding.severity}</span></td>
66
+ <td><code>${escapeText(finding.code)}</code></td>
67
+ <td>${escapeText(operation)}</td>
68
+ <td>${escapeText(finding.message)}${recommendation}<code class="pointer">${escapeText(finding.documentPointer ?? "")}</code></td>
69
+ </tr>`;
70
+ }).join("");
71
+ return `<section>
72
+ <div class="findings-head">
73
+ <h2>Findings (${findings.length})</h2>
74
+ <input type="search" data-search placeholder="Filter rule, path, message…" aria-label="Filter findings" />
75
+ </div>
76
+ <table>
77
+ <thead><tr><th>Severity</th><th>Rule</th><th>Operation</th><th>Detail</th></tr></thead>
78
+ <tbody data-findings>${rows}</tbody>
79
+ </table>
80
+ <p class="empty hidden" data-empty-findings>No findings match the current filter.</p>
81
+ </section>`;
82
+ };
83
+ const escapeText = (value) => value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
84
+ 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
+ //#endregion
3
210
  //#region src/plugin.ts
4
211
  const spectralPlugin = (options = {}) => {
5
212
  const runtime = createOpenApiLintRuntime(options);
@@ -79,6 +286,52 @@ const spectralPlugin = (options = {}) => {
79
286
  summary: "OpenAPI lint healthcheck"
80
287
  } });
81
288
  }
289
+ if (options.dashboard) {
290
+ const dashboardPath = options.dashboard.path ?? "/__openapi/dashboard";
291
+ plugin = plugin.get(dashboardPath, async ({ request, set }) => {
292
+ const fresh = new URL(request.url).searchParams.get("fresh") === "1";
293
+ const threshold = options.failOn ?? "error";
294
+ const currentApp = hostAppRef.current;
295
+ set.headers["content-type"] = "text/html; charset=utf-8";
296
+ if (!currentApp) {
297
+ set.status = 503;
298
+ return renderDashboard({
299
+ result: null,
300
+ threshold,
301
+ cached: false,
302
+ error: "OpenAPI lint runtime is not initialized yet.",
303
+ refreshPath: dashboardPath
304
+ });
305
+ }
306
+ try {
307
+ const usedCache = !fresh && runtime.latest !== null;
308
+ return renderDashboard({
309
+ result: usedCache ? runtime.latest : await runtime.run(currentApp, "manual"),
310
+ threshold,
311
+ cached: usedCache,
312
+ refreshPath: dashboardPath
313
+ });
314
+ } catch (error) {
315
+ if (error instanceof OpenApiLintThresholdError) return renderDashboard({
316
+ result: error.result,
317
+ threshold,
318
+ cached: false,
319
+ refreshPath: dashboardPath
320
+ });
321
+ set.status = 500;
322
+ return renderDashboard({
323
+ result: null,
324
+ threshold,
325
+ cached: false,
326
+ error: error instanceof Error ? error.message : String(error),
327
+ refreshPath: dashboardPath
328
+ });
329
+ }
330
+ }, { detail: {
331
+ hide: true,
332
+ summary: "OpenAPI lint dashboard"
333
+ } });
334
+ }
82
335
  return plugin;
83
336
  };
84
337
  //#endregion
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@opsydyn/elysia-spectral",
3
- "version": "0.5.2",
3
+ "version": "1.1.1",
4
4
  "description": "Thin Elysia plugin that lints generated OpenAPI documents with Spectral.",
5
5
  "packageManager": "bun@1.3.11",
6
6
  "publishConfig": {
@@ -38,7 +38,8 @@
38
38
  "build": "tsdown",
39
39
  "typecheck": "tsc --noEmit",
40
40
  "test": "bun test",
41
- "test:watch": "bun test --watch"
41
+ "test:watch": "bun test --watch",
42
+ "bench": "RUN_BENCH=1 bun test ./test/benchmark"
42
43
  },
43
44
  "keywords": [
44
45
  "elysia",
@@ -82,6 +83,7 @@
82
83
  "@types/node": "^25.6.0",
83
84
  "@types/signale": "^1.4.7",
84
85
  "elysia": "^1.4.0",
86
+ "fast-check": "^4.7.0",
85
87
  "tsdown": "^0.21.8",
86
88
  "typescript": "^5.8.3"
87
89
  }