@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 +31 -0
- package/README.md +35 -0
- package/dist/core/index.d.mts +1 -1
- package/dist/core/index.mjs +1 -1
- package/dist/{core-DTKNy6TU.mjs → core-BLJeXQ15.mjs} +17 -4
- package/dist/{index-11HnbLDN.d.mts → index-Dx83hCT9.d.mts} +5 -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,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
|
+

|
|
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
|
+

|
|
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.
|
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 };
|
|
@@ -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.
|
|
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-
|
|
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": "
|
|
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
|
}
|