@specglass/theme-default 0.0.3 → 0.0.5
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/dist/__tests__/api-endpoint-fallback.test.d.ts +1 -0
- package/dist/__tests__/api-endpoint-fallback.test.js +153 -0
- package/package.json +1 -1
- package/src/components/ApiEndpointFallback.astro +102 -0
- package/src/components/ApiExampleRequest.astro +19 -6
- package/src/components/ApiExampleResponse.astro +22 -11
- package/src/components/ApiNavigation.astro +70 -3
- package/src/layouts/ApiReferencePage.astro +73 -44
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for error endpoint slug generation, data contract, and
|
|
3
|
+
* the shared buildErrorEndpointSlug utility from @specglass/core.
|
|
4
|
+
*/
|
|
5
|
+
import { describe, it, expect } from "vitest";
|
|
6
|
+
import { buildEndpointSlug, buildErrorEndpointSlug, buildEndpointId } from "@specglass/core";
|
|
7
|
+
describe("buildErrorEndpointSlug", () => {
|
|
8
|
+
it("generates correct slug for a simple errored endpoint", () => {
|
|
9
|
+
const error = {
|
|
10
|
+
path: "/pets",
|
|
11
|
+
method: "get",
|
|
12
|
+
reason: "Complex polymorphism not supported",
|
|
13
|
+
rawSpec: { get: { summary: "List pets" } },
|
|
14
|
+
};
|
|
15
|
+
expect(buildErrorEndpointSlug(error)).toBe("_unsupported/get-pets");
|
|
16
|
+
});
|
|
17
|
+
it("generates correct slug for nested path with parameters", () => {
|
|
18
|
+
const error = {
|
|
19
|
+
path: "/users/{userId}/orders/{orderId}",
|
|
20
|
+
method: "patch",
|
|
21
|
+
reason: "Unsupported allOf/oneOf",
|
|
22
|
+
rawSpec: {},
|
|
23
|
+
};
|
|
24
|
+
expect(buildErrorEndpointSlug(error)).toBe("_unsupported/patch-users-userId-orders-orderId");
|
|
25
|
+
});
|
|
26
|
+
it("generates correct slug for root path", () => {
|
|
27
|
+
const error = {
|
|
28
|
+
path: "/",
|
|
29
|
+
method: "get",
|
|
30
|
+
reason: "Unsupported extension",
|
|
31
|
+
rawSpec: {},
|
|
32
|
+
};
|
|
33
|
+
expect(buildErrorEndpointSlug(error)).toBe("_unsupported/get-");
|
|
34
|
+
});
|
|
35
|
+
it("uses _unsupported prefix to avoid collision with valid endpoint slugs", () => {
|
|
36
|
+
// An errored endpoint at /pets GET should NOT collide with a valid
|
|
37
|
+
// endpoint at /pets GET (which gets slug "default/get-pets")
|
|
38
|
+
const error = {
|
|
39
|
+
path: "/pets",
|
|
40
|
+
method: "get",
|
|
41
|
+
reason: "test",
|
|
42
|
+
rawSpec: {},
|
|
43
|
+
};
|
|
44
|
+
const validEndpoint = {
|
|
45
|
+
path: "/pets",
|
|
46
|
+
method: "get",
|
|
47
|
+
tags: [],
|
|
48
|
+
summary: "",
|
|
49
|
+
description: "",
|
|
50
|
+
operationId: "",
|
|
51
|
+
deprecated: false,
|
|
52
|
+
parameters: [],
|
|
53
|
+
responses: [],
|
|
54
|
+
security: [],
|
|
55
|
+
requestBody: undefined,
|
|
56
|
+
};
|
|
57
|
+
const errorSlug = buildErrorEndpointSlug(error);
|
|
58
|
+
const validSlug = buildEndpointSlug(validEndpoint);
|
|
59
|
+
expect(errorSlug).not.toBe(validSlug);
|
|
60
|
+
expect(errorSlug).toContain("_unsupported/");
|
|
61
|
+
expect(validSlug).toContain("default/");
|
|
62
|
+
});
|
|
63
|
+
it("generates unique IDs for errored endpoints", () => {
|
|
64
|
+
const error = {
|
|
65
|
+
path: "/pets/{petId}",
|
|
66
|
+
method: "delete",
|
|
67
|
+
reason: "test",
|
|
68
|
+
rawSpec: {},
|
|
69
|
+
};
|
|
70
|
+
const errId = `${error.method}-${error.path}`;
|
|
71
|
+
expect(errId).toBe("delete-/pets/{petId}");
|
|
72
|
+
// This matches buildEndpointId behavior
|
|
73
|
+
const validEndpoint = {
|
|
74
|
+
path: "/pets/{petId}",
|
|
75
|
+
method: "delete",
|
|
76
|
+
tags: [],
|
|
77
|
+
summary: "",
|
|
78
|
+
description: "",
|
|
79
|
+
operationId: "",
|
|
80
|
+
deprecated: false,
|
|
81
|
+
parameters: [],
|
|
82
|
+
responses: [],
|
|
83
|
+
security: [],
|
|
84
|
+
requestBody: undefined,
|
|
85
|
+
};
|
|
86
|
+
expect(errId).toBe(buildEndpointId(validEndpoint));
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
describe("ApiEndpointError data contract", () => {
|
|
90
|
+
it("has all required fields for rendering", () => {
|
|
91
|
+
const error = {
|
|
92
|
+
path: "/complex-endpoint",
|
|
93
|
+
method: "post",
|
|
94
|
+
reason: "Complex polymorphism (allOf with discriminator) is not supported",
|
|
95
|
+
rawSpec: {
|
|
96
|
+
post: {
|
|
97
|
+
summary: "Create complex resource",
|
|
98
|
+
requestBody: {
|
|
99
|
+
content: {
|
|
100
|
+
"application/json": {
|
|
101
|
+
schema: {
|
|
102
|
+
allOf: [{ $ref: "#/components/schemas/Base" }],
|
|
103
|
+
discriminator: { propertyName: "type" },
|
|
104
|
+
},
|
|
105
|
+
},
|
|
106
|
+
},
|
|
107
|
+
},
|
|
108
|
+
},
|
|
109
|
+
},
|
|
110
|
+
};
|
|
111
|
+
// Verify all fields exist and are the correct types
|
|
112
|
+
expect(typeof error.path).toBe("string");
|
|
113
|
+
expect(typeof error.method).toBe("string");
|
|
114
|
+
expect(typeof error.reason).toBe("string");
|
|
115
|
+
expect(error.rawSpec).toBeDefined();
|
|
116
|
+
expect(typeof error.rawSpec).toBe("object");
|
|
117
|
+
});
|
|
118
|
+
it("rawSpec serializes to valid JSON for display", () => {
|
|
119
|
+
const error = {
|
|
120
|
+
path: "/test",
|
|
121
|
+
method: "get",
|
|
122
|
+
reason: "test",
|
|
123
|
+
rawSpec: {
|
|
124
|
+
nested: { deeply: { value: [1, 2, 3] } },
|
|
125
|
+
special: 'chars "in" strings',
|
|
126
|
+
},
|
|
127
|
+
};
|
|
128
|
+
const json = JSON.stringify(error.rawSpec, null, 2);
|
|
129
|
+
expect(() => JSON.parse(json)).not.toThrow();
|
|
130
|
+
expect(json).toContain("nested");
|
|
131
|
+
expect(json).toContain("deeply");
|
|
132
|
+
});
|
|
133
|
+
it("handles empty rawSpec gracefully", () => {
|
|
134
|
+
const error = {
|
|
135
|
+
path: "/test",
|
|
136
|
+
method: "get",
|
|
137
|
+
reason: "test",
|
|
138
|
+
rawSpec: {},
|
|
139
|
+
};
|
|
140
|
+
const json = JSON.stringify(error.rawSpec, null, 2);
|
|
141
|
+
expect(json).toBe("{}");
|
|
142
|
+
});
|
|
143
|
+
it("handles null rawSpec gracefully", () => {
|
|
144
|
+
const error = {
|
|
145
|
+
path: "/test",
|
|
146
|
+
method: "get",
|
|
147
|
+
reason: "test",
|
|
148
|
+
rawSpec: null,
|
|
149
|
+
};
|
|
150
|
+
const json = JSON.stringify(error.rawSpec, null, 2);
|
|
151
|
+
expect(json).toBe("null");
|
|
152
|
+
});
|
|
153
|
+
});
|
package/package.json
CHANGED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* ApiEndpointFallback.astro — Renders a "Manual Documentation Required" placeholder
|
|
4
|
+
* for endpoints where the OpenAPI parser encountered unsupported spec patterns (FR40).
|
|
5
|
+
*
|
|
6
|
+
* Shows the error reason and raw spec data in a collapsible, syntax-highlighted block.
|
|
7
|
+
*/
|
|
8
|
+
import type { ApiEndpointError } from "@specglass/core";
|
|
9
|
+
import { CopyButton } from "../islands/CopyButton";
|
|
10
|
+
import { highlight } from "../utils/shiki";
|
|
11
|
+
|
|
12
|
+
export interface Props {
|
|
13
|
+
error: ApiEndpointError;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const { error } = Astro.props;
|
|
17
|
+
|
|
18
|
+
const methodColors: Record<string, string> = {
|
|
19
|
+
get: "bg-emerald-500/15 text-emerald-700 dark:text-emerald-400 border-emerald-500/30",
|
|
20
|
+
post: "bg-blue-500/15 text-blue-700 dark:text-blue-400 border-blue-500/30",
|
|
21
|
+
put: "bg-amber-500/15 text-amber-700 dark:text-amber-400 border-amber-500/30",
|
|
22
|
+
patch: "bg-yellow-500/15 text-yellow-700 dark:text-yellow-400 border-yellow-500/30",
|
|
23
|
+
delete: "bg-red-500/15 text-red-700 dark:text-red-400 border-red-500/30",
|
|
24
|
+
options: "bg-purple-500/15 text-purple-700 dark:text-purple-400 border-purple-500/30",
|
|
25
|
+
head: "bg-gray-500/15 text-gray-700 dark:text-gray-400 border-gray-500/30",
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
const methodColor = methodColors[error.method.toLowerCase()] ?? methodColors.get;
|
|
29
|
+
const rawSpecJson = JSON.stringify(error.rawSpec, null, 2);
|
|
30
|
+
const highlightedSpec = await highlight(rawSpecJson, "json");
|
|
31
|
+
---
|
|
32
|
+
|
|
33
|
+
<div id={`endpoint-${error.method}-${error.path.replace(/[{}\\\/]/g, "-")}`} class="mb-8">
|
|
34
|
+
{/* Method + Path header */}
|
|
35
|
+
<div class="flex items-center gap-3 mb-4">
|
|
36
|
+
<span
|
|
37
|
+
class:list={[
|
|
38
|
+
"inline-flex items-center px-3 py-1 rounded-full text-xs font-bold uppercase tracking-wider border",
|
|
39
|
+
methodColor,
|
|
40
|
+
]}
|
|
41
|
+
>
|
|
42
|
+
{error.method.toUpperCase()}
|
|
43
|
+
</span>
|
|
44
|
+
<code class="text-lg font-mono text-text font-semibold break-all">
|
|
45
|
+
{error.path}
|
|
46
|
+
</code>
|
|
47
|
+
</div>
|
|
48
|
+
|
|
49
|
+
{/* Warning alert */}
|
|
50
|
+
<div
|
|
51
|
+
role="alert"
|
|
52
|
+
class="rounded-lg border border-amber-500/30 bg-amber-50 dark:bg-amber-950/20 px-5 py-4 mb-6"
|
|
53
|
+
>
|
|
54
|
+
<div class="flex items-start gap-3">
|
|
55
|
+
<span class="text-amber-600 dark:text-amber-400 text-xl mt-0.5" aria-hidden="true">⚠️</span>
|
|
56
|
+
<div>
|
|
57
|
+
<h3 class="text-base font-semibold text-amber-800 dark:text-amber-200 mt-0 mb-1">
|
|
58
|
+
Manual Documentation Required
|
|
59
|
+
</h3>
|
|
60
|
+
<p class="text-sm text-amber-700 dark:text-amber-300 leading-relaxed m-0">
|
|
61
|
+
This endpoint uses an OpenAPI spec pattern that couldn't be fully parsed.
|
|
62
|
+
{
|
|
63
|
+
error.reason && (
|
|
64
|
+
<span class="block mt-1 text-amber-600 dark:text-amber-400 font-medium">
|
|
65
|
+
{error.reason}
|
|
66
|
+
</span>
|
|
67
|
+
)
|
|
68
|
+
}
|
|
69
|
+
</p>
|
|
70
|
+
</div>
|
|
71
|
+
</div>
|
|
72
|
+
</div>
|
|
73
|
+
|
|
74
|
+
{/* Raw spec data — collapsed by default */}
|
|
75
|
+
<details class="rounded-lg border border-border bg-surface-code overflow-hidden">
|
|
76
|
+
<summary
|
|
77
|
+
class="cursor-pointer px-4 py-3 text-sm font-medium text-text-muted hover:text-text transition-colors select-none flex items-center gap-2"
|
|
78
|
+
>
|
|
79
|
+
<svg
|
|
80
|
+
class="w-4 h-4 transition-transform open:rotate-90"
|
|
81
|
+
viewBox="0 0 20 20"
|
|
82
|
+
fill="currentColor"
|
|
83
|
+
aria-hidden="true"
|
|
84
|
+
>
|
|
85
|
+
<path
|
|
86
|
+
fill-rule="evenodd"
|
|
87
|
+
d="M7.21 14.77a.75.75 0 01.02-1.06L11.168 10 7.23 6.29a.75.75 0 111.04-1.08l4.5 4.25a.75.75 0 010 1.08l-4.5 4.25a.75.75 0 01-1.06-.02z"
|
|
88
|
+
clip-rule="evenodd"></path>
|
|
89
|
+
</svg>
|
|
90
|
+
View raw OpenAPI spec data
|
|
91
|
+
</summary>
|
|
92
|
+
<div class="relative group border-t border-border">
|
|
93
|
+
<div
|
|
94
|
+
class="[&_pre]:overflow-x-auto [&_pre]:p-4 [&_pre]:text-sm [&_pre]:leading-relaxed [&_pre]:m-0"
|
|
95
|
+
set:html={highlightedSpec}
|
|
96
|
+
/>
|
|
97
|
+
<div class="absolute top-2 right-2 opacity-0 group-hover:opacity-100 transition-opacity">
|
|
98
|
+
<CopyButton client:idle code={rawSpecJson} />
|
|
99
|
+
</div>
|
|
100
|
+
</div>
|
|
101
|
+
</details>
|
|
102
|
+
</div>
|
|
@@ -5,10 +5,12 @@
|
|
|
5
5
|
* Generates cURL, Python, and Node.js request examples from endpoint data.
|
|
6
6
|
* Uses a tab interface with localStorage persistence for language preference.
|
|
7
7
|
* Each tab includes a copy button for the code snippet.
|
|
8
|
+
* Code blocks use Shiki dual-theme highlighting (AC7).
|
|
8
9
|
*/
|
|
9
10
|
import type { ApiEndpoint } from "@specglass/core";
|
|
10
11
|
import { generateCurlExample, generatePythonExample, generateNodeExample } from "@specglass/core";
|
|
11
12
|
import { CopyButton } from "../islands/CopyButton";
|
|
13
|
+
import { highlight } from "../utils/shiki";
|
|
12
14
|
|
|
13
15
|
export interface Props {
|
|
14
16
|
endpoint: ApiEndpoint;
|
|
@@ -19,7 +21,7 @@ const { endpoint, baseUrl = "https://api.example.com" } = Astro.props;
|
|
|
19
21
|
|
|
20
22
|
const options = { baseUrl };
|
|
21
23
|
|
|
22
|
-
const
|
|
24
|
+
const rawLanguages = [
|
|
23
25
|
{ id: "curl", label: "cURL", code: generateCurlExample(endpoint, options), lang: "bash" },
|
|
24
26
|
{ id: "python", label: "Python", code: generatePythonExample(endpoint, options), lang: "python" },
|
|
25
27
|
{
|
|
@@ -30,6 +32,14 @@ const languages = [
|
|
|
30
32
|
},
|
|
31
33
|
];
|
|
32
34
|
|
|
35
|
+
// Pre-render Shiki highlighting at build time
|
|
36
|
+
const languages = await Promise.all(
|
|
37
|
+
rawLanguages.map(async (item) => ({
|
|
38
|
+
...item,
|
|
39
|
+
highlighted: await highlight(item.code, item.lang),
|
|
40
|
+
})),
|
|
41
|
+
);
|
|
42
|
+
|
|
33
43
|
const tabGroupId = `api-req-${endpoint.method}-${endpoint.path.replace(/[{}/]/g, "-")}`;
|
|
34
44
|
---
|
|
35
45
|
|
|
@@ -81,9 +91,10 @@ const tabGroupId = `api-req-${endpoint.method}-${endpoint.path.replace(/[{}/]/g,
|
|
|
81
91
|
class={i === 0 ? "" : "hidden"}
|
|
82
92
|
>
|
|
83
93
|
<div class="relative group">
|
|
84
|
-
<
|
|
85
|
-
|
|
86
|
-
|
|
94
|
+
<div
|
|
95
|
+
class="[&_pre]:overflow-x-auto [&_pre]:p-4 [&_pre]:text-sm [&_pre]:leading-relaxed [&_pre]:m-0"
|
|
96
|
+
set:html={lang.highlighted}
|
|
97
|
+
/>
|
|
87
98
|
<div class="absolute top-2 right-2 opacity-0 group-hover:opacity-100 transition-opacity">
|
|
88
99
|
<CopyButton client:idle code={lang.code} />
|
|
89
100
|
</div>
|
|
@@ -110,7 +121,7 @@ const tabGroupId = `api-req-${endpoint.method}-${endpoint.path.replace(/[{}/]/g,
|
|
|
110
121
|
activateTab(saved);
|
|
111
122
|
}
|
|
112
123
|
} catch {
|
|
113
|
-
// localStorage unavailable
|
|
124
|
+
// noop — localStorage unavailable (e.g., private browsing)
|
|
114
125
|
}
|
|
115
126
|
|
|
116
127
|
function activateTab(langId: string) {
|
|
@@ -142,7 +153,9 @@ const tabGroupId = `api-req-${endpoint.method}-${endpoint.path.replace(/[{}/]/g,
|
|
|
142
153
|
// Persist and sync
|
|
143
154
|
try {
|
|
144
155
|
localStorage.setItem(STORAGE_KEY, lang);
|
|
145
|
-
} catch {
|
|
156
|
+
} catch {
|
|
157
|
+
/* noop */
|
|
158
|
+
}
|
|
146
159
|
// Broadcast sync event for other tab groups on the page
|
|
147
160
|
document.dispatchEvent(
|
|
148
161
|
new CustomEvent("specglass-lang-sync", { detail: { syncKey: SYNC_KEY, lang } }),
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
import type { ApiResponse as ApiResponseType } from "@specglass/core";
|
|
10
10
|
import { extractMediaTypeExample } from "@specglass/core";
|
|
11
11
|
import { CopyButton } from "../islands/CopyButton";
|
|
12
|
+
import { highlight } from "../utils/shiki";
|
|
12
13
|
|
|
13
14
|
export interface Props {
|
|
14
15
|
responses: ApiResponseType[];
|
|
@@ -28,7 +29,7 @@ function getStatusColor(code: string): string {
|
|
|
28
29
|
return "bg-gray-500/15 text-gray-700 dark:text-gray-400 border-gray-500/30";
|
|
29
30
|
}
|
|
30
31
|
|
|
31
|
-
const
|
|
32
|
+
const responseExamplesRaw = responses.map((resp) => {
|
|
32
33
|
let exampleJson: string | null = null;
|
|
33
34
|
let contentType: string | null = null;
|
|
34
35
|
|
|
@@ -52,6 +53,14 @@ const responseExamples = responses.map((resp) => {
|
|
|
52
53
|
statusColor: getStatusColor(resp.statusCode),
|
|
53
54
|
};
|
|
54
55
|
});
|
|
56
|
+
|
|
57
|
+
// Pre-render Shiki highlighting at build time
|
|
58
|
+
const responseExamples = await Promise.all(
|
|
59
|
+
responseExamplesRaw.map(async (resp) => ({
|
|
60
|
+
...resp,
|
|
61
|
+
highlighted: resp.exampleJson ? await highlight(resp.exampleJson, "json") : null,
|
|
62
|
+
})),
|
|
63
|
+
);
|
|
55
64
|
---
|
|
56
65
|
|
|
57
66
|
<div class="mt-6">
|
|
@@ -80,13 +89,14 @@ const responseExamples = responses.map((resp) => {
|
|
|
80
89
|
</span>
|
|
81
90
|
)}
|
|
82
91
|
</div>
|
|
83
|
-
{resp.
|
|
92
|
+
{resp.highlighted ? (
|
|
84
93
|
<div class="relative group">
|
|
85
|
-
<
|
|
86
|
-
|
|
87
|
-
|
|
94
|
+
<div
|
|
95
|
+
class="[&_pre]:overflow-x-auto [&_pre]:p-4 [&_pre]:text-sm [&_pre]:leading-relaxed [&_pre]:m-0"
|
|
96
|
+
set:html={resp.highlighted}
|
|
97
|
+
/>
|
|
88
98
|
<div class="absolute top-2 right-2 opacity-0 group-hover:opacity-100 transition-opacity">
|
|
89
|
-
<CopyButton client:idle code={resp.exampleJson} />
|
|
99
|
+
<CopyButton client:idle code={resp.exampleJson!} />
|
|
90
100
|
</div>
|
|
91
101
|
</div>
|
|
92
102
|
) : (
|
|
@@ -111,13 +121,14 @@ const responseExamples = responses.map((resp) => {
|
|
|
111
121
|
</span>
|
|
112
122
|
)}
|
|
113
123
|
</summary>
|
|
114
|
-
{resp.
|
|
124
|
+
{resp.highlighted ? (
|
|
115
125
|
<div class="relative group border-t border-border">
|
|
116
|
-
<
|
|
117
|
-
|
|
118
|
-
|
|
126
|
+
<div
|
|
127
|
+
class="[&_pre]:overflow-x-auto [&_pre]:p-4 [&_pre]:text-sm [&_pre]:leading-relaxed [&_pre]:m-0"
|
|
128
|
+
set:html={resp.highlighted}
|
|
129
|
+
/>
|
|
119
130
|
<div class="absolute top-2 right-2 opacity-0 group-hover:opacity-100 transition-opacity">
|
|
120
|
-
<CopyButton client:idle code={resp.exampleJson} />
|
|
131
|
+
<CopyButton client:idle code={resp.exampleJson!} />
|
|
121
132
|
</div>
|
|
122
133
|
</div>
|
|
123
134
|
) : (
|
|
@@ -2,17 +2,19 @@
|
|
|
2
2
|
/**
|
|
3
3
|
* ApiNavigation.astro — Sidebar navigation for API reference endpoints.
|
|
4
4
|
* Groups endpoints by tag and highlights the current endpoint.
|
|
5
|
+
* Also shows errored endpoints with a warning indicator (FR40).
|
|
5
6
|
*/
|
|
6
|
-
import type { ApiEndpoint } from "@specglass/core";
|
|
7
|
-
import { buildEndpointSlug, buildEndpointId } from "@specglass/core";
|
|
7
|
+
import type { ApiEndpoint, ApiEndpointError } from "@specglass/core";
|
|
8
|
+
import { buildEndpointSlug, buildErrorEndpointSlug, buildEndpointId } from "@specglass/core";
|
|
8
9
|
|
|
9
10
|
export interface Props {
|
|
10
11
|
endpoints: ApiEndpoint[];
|
|
12
|
+
errors?: ApiEndpointError[];
|
|
11
13
|
currentEndpointId: string;
|
|
12
14
|
basePath: string;
|
|
13
15
|
}
|
|
14
16
|
|
|
15
|
-
const { endpoints, currentEndpointId, basePath } = Astro.props;
|
|
17
|
+
const { endpoints, errors = [], currentEndpointId, basePath } = Astro.props;
|
|
16
18
|
|
|
17
19
|
// Group endpoints by tag
|
|
18
20
|
const grouped = new Map<string, ApiEndpoint[]>();
|
|
@@ -88,6 +90,71 @@ const methodBadgeColors: Record<string, string> = {
|
|
|
88
90
|
</div>
|
|
89
91
|
))
|
|
90
92
|
}
|
|
93
|
+
{/* Errored endpoints section (FR40 fallback) */}
|
|
94
|
+
{
|
|
95
|
+
errors.length > 0 && (
|
|
96
|
+
<div class="mt-4 pt-4 border-t border-border">
|
|
97
|
+
<button
|
|
98
|
+
class="flex w-full items-center gap-2 text-xs font-semibold text-amber-600 dark:text-amber-400 uppercase tracking-wider px-3 py-2 hover:text-amber-700 dark:hover:text-amber-300 transition-colors"
|
|
99
|
+
aria-expanded="true"
|
|
100
|
+
data-api-nav-group="unsupported"
|
|
101
|
+
>
|
|
102
|
+
<svg
|
|
103
|
+
class="h-3 w-3 shrink-0 transition-transform rotate-90"
|
|
104
|
+
viewBox="0 0 12 12"
|
|
105
|
+
fill="currentColor"
|
|
106
|
+
aria-hidden="true"
|
|
107
|
+
>
|
|
108
|
+
<path d="M4.5 2l4 4-4 4" />
|
|
109
|
+
</svg>
|
|
110
|
+
<span aria-hidden="true" class="mr-0.5">
|
|
111
|
+
⚠️
|
|
112
|
+
</span>{" "}
|
|
113
|
+
Unsupported
|
|
114
|
+
</button>
|
|
115
|
+
<ul class="ml-3 space-y-0.5" data-api-nav-items="unsupported">
|
|
116
|
+
{errors.map((err) => {
|
|
117
|
+
const errId = `${err.method}-${err.path}`;
|
|
118
|
+
const isActive = errId === currentEndpointId;
|
|
119
|
+
const slug = buildErrorEndpointSlug(err);
|
|
120
|
+
const badgeColor = methodBadgeColors[err.method.toLowerCase()] ?? methodBadgeColors.get;
|
|
121
|
+
|
|
122
|
+
return (
|
|
123
|
+
<li>
|
|
124
|
+
<a
|
|
125
|
+
href={`${basePath}/${slug}`}
|
|
126
|
+
class:list={[
|
|
127
|
+
"flex items-center gap-2 px-3 py-1.5 rounded-md text-xs transition-colors group",
|
|
128
|
+
isActive
|
|
129
|
+
? "bg-amber-500/10 text-amber-700 dark:text-amber-400 font-medium"
|
|
130
|
+
: "text-text-muted/60 hover:bg-surface-raised hover:text-text-muted",
|
|
131
|
+
]}
|
|
132
|
+
aria-current={isActive ? "page" : undefined}
|
|
133
|
+
title={`Unsupported: ${err.reason}`}
|
|
134
|
+
>
|
|
135
|
+
<span
|
|
136
|
+
class:list={[
|
|
137
|
+
"inline-flex w-12 justify-center shrink-0 px-1 py-0.5 rounded text-[10px] font-bold uppercase tracking-wider opacity-60",
|
|
138
|
+
badgeColor,
|
|
139
|
+
]}
|
|
140
|
+
>
|
|
141
|
+
{err.method.toUpperCase()}
|
|
142
|
+
</span>
|
|
143
|
+
<span class="font-mono truncate opacity-60">{err.path}</span>
|
|
144
|
+
<span
|
|
145
|
+
class="text-amber-500 dark:text-amber-400 text-[10px] ml-auto shrink-0"
|
|
146
|
+
aria-hidden="true"
|
|
147
|
+
>
|
|
148
|
+
⚠️
|
|
149
|
+
</span>
|
|
150
|
+
</a>
|
|
151
|
+
</li>
|
|
152
|
+
);
|
|
153
|
+
})}
|
|
154
|
+
</ul>
|
|
155
|
+
</div>
|
|
156
|
+
)
|
|
157
|
+
}
|
|
91
158
|
</nav>
|
|
92
159
|
|
|
93
160
|
<script>
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
* ApiReferencePage.astro — Layout for API reference endpoint pages.
|
|
4
4
|
* Mirrors DocPage structure (Header, Sidebar, Footer, mobile menu) but
|
|
5
5
|
* replaces the Table of Contents with API endpoint navigation.
|
|
6
|
+
* Supports fallback rendering for errored endpoints (FR40).
|
|
6
7
|
*/
|
|
7
8
|
import Header from "../components/Header.astro";
|
|
8
9
|
import Footer from "../components/Footer.astro";
|
|
@@ -13,26 +14,32 @@ import ApiResponse from "../components/ApiResponse.astro";
|
|
|
13
14
|
import ApiAuth from "../components/ApiAuth.astro";
|
|
14
15
|
import ApiExampleRequest from "../components/ApiExampleRequest.astro";
|
|
15
16
|
import ApiExampleResponse from "../components/ApiExampleResponse.astro";
|
|
17
|
+
import ApiEndpointFallback from "../components/ApiEndpointFallback.astro";
|
|
16
18
|
import { ThemeToggle } from "../islands/ThemeToggle";
|
|
17
19
|
import { SearchPalette } from "../islands/SearchPalette";
|
|
18
20
|
import "../styles/global.css";
|
|
19
21
|
import type {
|
|
20
22
|
NavigationTree,
|
|
21
23
|
ApiEndpoint as ApiEndpointType,
|
|
24
|
+
ApiEndpointError,
|
|
22
25
|
ApiSecurityRequirement,
|
|
23
26
|
SpecglassConfig,
|
|
24
27
|
} from "@specglass/core";
|
|
25
28
|
import { generateThemeCSS } from "../utils/theme-css";
|
|
26
29
|
|
|
27
30
|
export interface Props {
|
|
28
|
-
/** The current endpoint to display */
|
|
29
|
-
endpoint
|
|
31
|
+
/** The current endpoint to display (undefined for errored endpoints) */
|
|
32
|
+
endpoint?: ApiEndpointType;
|
|
33
|
+
/** Error data for endpoints with unsupported patterns (FR40) */
|
|
34
|
+
endpointError?: ApiEndpointError;
|
|
30
35
|
/** Documentation navigation tree */
|
|
31
36
|
navigation: NavigationTree;
|
|
32
37
|
/** Site configuration */
|
|
33
38
|
config: SpecglassConfig;
|
|
34
|
-
/** All endpoints for the sidebar navigation */
|
|
39
|
+
/** All valid endpoints for the sidebar navigation */
|
|
35
40
|
allEndpoints: ApiEndpointType[];
|
|
41
|
+
/** All errored endpoints for the sidebar navigation */
|
|
42
|
+
allErrors?: ApiEndpointError[];
|
|
36
43
|
/** Spec-level information */
|
|
37
44
|
specInfo: {
|
|
38
45
|
title: string;
|
|
@@ -42,15 +49,28 @@ export interface Props {
|
|
|
42
49
|
securitySchemes?: Record<string, ApiSecurityRequirement>;
|
|
43
50
|
}
|
|
44
51
|
|
|
45
|
-
const {
|
|
52
|
+
const {
|
|
53
|
+
endpoint,
|
|
54
|
+
endpointError,
|
|
55
|
+
navigation,
|
|
56
|
+
config,
|
|
57
|
+
allEndpoints,
|
|
58
|
+
allErrors = [],
|
|
59
|
+
specInfo,
|
|
60
|
+
securitySchemes,
|
|
61
|
+
} = Astro.props;
|
|
62
|
+
const isErrorPage = !!endpointError;
|
|
46
63
|
const themeCSS = generateThemeCSS(config);
|
|
47
64
|
|
|
48
|
-
const
|
|
49
|
-
const
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
`
|
|
53
|
-
|
|
65
|
+
const displayMethod = isErrorPage ? endpointError!.method : endpoint!.method;
|
|
66
|
+
const displayPath = isErrorPage ? endpointError!.path : endpoint!.path;
|
|
67
|
+
const pageTitle = `${displayMethod.toUpperCase()} ${displayPath} — ${specInfo.title} API`;
|
|
68
|
+
const pageDescription = isErrorPage
|
|
69
|
+
? `Manual documentation required for ${displayMethod.toUpperCase()} ${displayPath}`
|
|
70
|
+
: endpoint!.summary ||
|
|
71
|
+
endpoint!.description ||
|
|
72
|
+
`API reference for ${displayMethod.toUpperCase()} ${displayPath}`;
|
|
73
|
+
const currentEndpointId = `${displayMethod}-${displayPath}`;
|
|
54
74
|
---
|
|
55
75
|
|
|
56
76
|
<html lang="en" class="dark">
|
|
@@ -75,7 +95,9 @@ const currentEndpointId = `${endpoint.method}-${endpoint.path}`;
|
|
|
75
95
|
var stored = null;
|
|
76
96
|
try {
|
|
77
97
|
stored = localStorage.getItem("specglass-theme");
|
|
78
|
-
} catch (e) {
|
|
98
|
+
} catch (e) {
|
|
99
|
+
/* noop */
|
|
100
|
+
}
|
|
79
101
|
var theme = stored === "dark" || stored === "light" ? stored : "dark";
|
|
80
102
|
if (theme === "dark") {
|
|
81
103
|
document.documentElement.classList.add("dark");
|
|
@@ -121,6 +143,7 @@ const currentEndpointId = `${endpoint.method}-${endpoint.path}`;
|
|
|
121
143
|
|
|
122
144
|
<ApiNavigation
|
|
123
145
|
endpoints={allEndpoints}
|
|
146
|
+
errors={allErrors}
|
|
124
147
|
currentEndpointId={currentEndpointId}
|
|
125
148
|
basePath="/api-reference"
|
|
126
149
|
/>
|
|
@@ -142,6 +165,7 @@ const currentEndpointId = `${endpoint.method}-${endpoint.path}`;
|
|
|
142
165
|
</div>
|
|
143
166
|
<ApiNavigation
|
|
144
167
|
endpoints={allEndpoints}
|
|
168
|
+
errors={allErrors}
|
|
145
169
|
currentEndpointId={currentEndpointId}
|
|
146
170
|
basePath="/api-reference"
|
|
147
171
|
/>
|
|
@@ -150,44 +174,49 @@ const currentEndpointId = `${endpoint.method}-${endpoint.path}`;
|
|
|
150
174
|
<!-- Main content area -->
|
|
151
175
|
<main id="main-content" class="flex-1 min-w-0 md:ml-(--width-sidebar)" data-pagefind-body>
|
|
152
176
|
<article class="max-w-(--width-content-max) mx-auto px-(--spacing-page) py-8">
|
|
153
|
-
<!-- Endpoint header -->
|
|
154
|
-
<ApiEndpoint endpoint={endpoint} />
|
|
155
|
-
|
|
156
|
-
<!-- Authentication -->
|
|
157
177
|
{
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
178
|
+
isErrorPage ? (
|
|
179
|
+
/* Fallback rendering for errored endpoints (FR40) */
|
|
180
|
+
<ApiEndpointFallback error={endpointError!} />
|
|
181
|
+
) : (
|
|
182
|
+
<>
|
|
183
|
+
{/* Endpoint header */}
|
|
184
|
+
<ApiEndpoint endpoint={endpoint!} />
|
|
162
185
|
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
<h2 class="text-lg font-semibold text-text mb-4 mt-0">Parameters</h2>
|
|
168
|
-
<ApiParameters
|
|
169
|
-
parameters={endpoint.parameters}
|
|
170
|
-
requestBody={endpoint.requestBody}
|
|
171
|
-
/>
|
|
172
|
-
</div>
|
|
173
|
-
)
|
|
174
|
-
}
|
|
186
|
+
{/* Authentication */}
|
|
187
|
+
{endpoint!.security && endpoint!.security.length > 0 && (
|
|
188
|
+
<ApiAuth security={endpoint!.security} securitySchemes={securitySchemes} />
|
|
189
|
+
)}
|
|
175
190
|
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
191
|
+
{/* Parameters & Request Body */}
|
|
192
|
+
{(endpoint!.parameters.length > 0 || endpoint!.requestBody) && (
|
|
193
|
+
<div>
|
|
194
|
+
<h2 class="text-lg font-semibold text-text mb-4 mt-0">Parameters</h2>
|
|
195
|
+
<ApiParameters
|
|
196
|
+
parameters={endpoint!.parameters}
|
|
197
|
+
requestBody={endpoint!.requestBody}
|
|
198
|
+
/>
|
|
199
|
+
</div>
|
|
200
|
+
)}
|
|
185
201
|
|
|
186
|
-
|
|
187
|
-
|
|
202
|
+
{/* Responses */}
|
|
203
|
+
{endpoint!.responses.length > 0 && (
|
|
204
|
+
<div>
|
|
205
|
+
<h2 class="text-lg font-semibold text-text mb-4">Responses</h2>
|
|
206
|
+
<ApiResponse responses={endpoint!.responses} />
|
|
207
|
+
</div>
|
|
208
|
+
)}
|
|
188
209
|
|
|
189
|
-
|
|
190
|
-
|
|
210
|
+
{/* Request Example (code tabs: cURL, Python, Node.js) */}
|
|
211
|
+
<ApiExampleRequest endpoint={endpoint!} />
|
|
212
|
+
|
|
213
|
+
{/* Response Examples (per status code) */}
|
|
214
|
+
{endpoint!.responses.length > 0 && (
|
|
215
|
+
<ApiExampleResponse responses={endpoint!.responses} />
|
|
216
|
+
)}
|
|
217
|
+
</>
|
|
218
|
+
)
|
|
219
|
+
}
|
|
191
220
|
</article>
|
|
192
221
|
</main>
|
|
193
222
|
</div>
|