@opsydyn/elysia-spectral 1.0.0 → 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 +20 -0
- package/dist/core/index.d.mts +1 -1
- package/dist/core/index.mjs +1 -1
- package/dist/{core-BIi33eA1.mjs → core-BLJeXQ15.mjs} +3 -3
- package/dist/{index--_yjTKg8.d.mts → index-Dx83hCT9.d.mts} +3 -0
- package/dist/index.d.mts +1 -1
- package/dist/index.mjs +254 -1
- package/package.json +4 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,25 @@
|
|
|
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
|
+
|
|
3
23
|
## [1.0.0](https://github.com/opsydyn/elysia-spectral/compare/v0.5.2...v1.0.0) (2026-04-15)
|
|
4
24
|
|
|
5
25
|
|
package/dist/core/index.d.mts
CHANGED
|
@@ -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
|
|
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 };
|
package/dist/core/index.mjs
CHANGED
|
@@ -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-
|
|
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 };
|
|
@@ -1204,17 +1204,17 @@ const createOpenApiLintRuntime = (options = {}) => {
|
|
|
1204
1204
|
result.ok = !shouldFail(result, result.failOn);
|
|
1205
1205
|
await writeOutputSinks(result, spec, options, artifactWriteFailureMode);
|
|
1206
1206
|
runtime.latest = result;
|
|
1207
|
+
finalizeRuntimeRun(runtime, startedAt);
|
|
1208
|
+
result.durationMs = runtime.durationMs;
|
|
1207
1209
|
reporter.complete("OpenAPI lint completed.");
|
|
1208
1210
|
enforceThreshold(result, options.failOn ?? "error");
|
|
1209
1211
|
runtime.status = "passed";
|
|
1210
1212
|
runtime.lastSuccess = result;
|
|
1211
|
-
finalizeRuntimeRun(runtime, startedAt);
|
|
1212
|
-
result.durationMs = runtime.durationMs;
|
|
1213
1213
|
return result;
|
|
1214
1214
|
} catch (error) {
|
|
1215
1215
|
runtime.status = "failed";
|
|
1216
1216
|
runtime.lastFailure = toRuntimeFailure(error);
|
|
1217
|
-
finalizeRuntimeRun(runtime, startedAt);
|
|
1217
|
+
if (runtime.durationMs === null) finalizeRuntimeRun(runtime, startedAt);
|
|
1218
1218
|
throw error;
|
|
1219
1219
|
}
|
|
1220
1220
|
})();
|
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
|
|
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-
|
|
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, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
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": "1.
|
|
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
|
}
|