@specglass/theme-default 0.0.2
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__/code-tabs.test.d.ts +2 -0
- package/dist/__tests__/code-tabs.test.d.ts.map +1 -0
- package/dist/__tests__/code-tabs.test.js +219 -0
- package/dist/__tests__/code-tabs.test.js.map +1 -0
- package/dist/__tests__/copy-button.test.d.ts +2 -0
- package/dist/__tests__/copy-button.test.d.ts.map +1 -0
- package/dist/__tests__/copy-button.test.js +116 -0
- package/dist/__tests__/copy-button.test.js.map +1 -0
- package/dist/__tests__/search-palette.test.d.ts +2 -0
- package/dist/__tests__/search-palette.test.d.ts.map +1 -0
- package/dist/__tests__/search-palette.test.js +71 -0
- package/dist/__tests__/search-palette.test.js.map +1 -0
- package/dist/__tests__/shiki.test.d.ts +2 -0
- package/dist/__tests__/shiki.test.d.ts.map +1 -0
- package/dist/__tests__/shiki.test.js +37 -0
- package/dist/__tests__/shiki.test.js.map +1 -0
- package/dist/__tests__/theme-css.test.d.ts +2 -0
- package/dist/__tests__/theme-css.test.d.ts.map +1 -0
- package/dist/__tests__/theme-css.test.js +124 -0
- package/dist/__tests__/theme-css.test.js.map +1 -0
- package/dist/__tests__/theme-helpers.test.d.ts +2 -0
- package/dist/__tests__/theme-helpers.test.d.ts.map +1 -0
- package/dist/__tests__/theme-helpers.test.js +81 -0
- package/dist/__tests__/theme-helpers.test.js.map +1 -0
- package/dist/index.d.ts +63 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +13 -0
- package/dist/index.js.map +1 -0
- package/dist/islands/CodeTabs.d.ts +21 -0
- package/dist/islands/CodeTabs.d.ts.map +1 -0
- package/dist/islands/CodeTabs.js +125 -0
- package/dist/islands/CodeTabs.js.map +1 -0
- package/dist/islands/CopyButton.d.ts +16 -0
- package/dist/islands/CopyButton.d.ts.map +1 -0
- package/dist/islands/CopyButton.js +54 -0
- package/dist/islands/CopyButton.js.map +1 -0
- package/dist/islands/SearchPalette.d.ts +2 -0
- package/dist/islands/SearchPalette.d.ts.map +1 -0
- package/dist/islands/SearchPalette.js +109 -0
- package/dist/islands/SearchPalette.js.map +1 -0
- package/dist/islands/SearchResults.d.ts +2 -0
- package/dist/islands/SearchResults.d.ts.map +1 -0
- package/dist/islands/SearchResults.js +130 -0
- package/dist/islands/SearchResults.js.map +1 -0
- package/dist/islands/ThemeToggle.d.ts +12 -0
- package/dist/islands/ThemeToggle.d.ts.map +1 -0
- package/dist/islands/ThemeToggle.js +43 -0
- package/dist/islands/ThemeToggle.js.map +1 -0
- package/dist/layouts/DocPage.test.d.ts +2 -0
- package/dist/layouts/DocPage.test.d.ts.map +1 -0
- package/dist/layouts/DocPage.test.js +165 -0
- package/dist/layouts/DocPage.test.js.map +1 -0
- package/dist/lib/utils.d.ts +10 -0
- package/dist/lib/utils.d.ts.map +1 -0
- package/dist/lib/utils.js +13 -0
- package/dist/lib/utils.js.map +1 -0
- package/dist/scripts/code-block-enhancer.d.ts +16 -0
- package/dist/scripts/code-block-enhancer.d.ts.map +1 -0
- package/dist/scripts/code-block-enhancer.js +55 -0
- package/dist/scripts/code-block-enhancer.js.map +1 -0
- package/dist/ui/command.d.ts +87 -0
- package/dist/ui/command.d.ts.map +1 -0
- package/dist/ui/command.js +28 -0
- package/dist/ui/command.js.map +1 -0
- package/dist/ui/dialog.d.ts +20 -0
- package/dist/ui/dialog.d.ts.map +1 -0
- package/dist/ui/dialog.js +22 -0
- package/dist/ui/dialog.js.map +1 -0
- package/dist/utils/parse-highlight-range.d.ts +12 -0
- package/dist/utils/parse-highlight-range.d.ts.map +1 -0
- package/dist/utils/parse-highlight-range.js +40 -0
- package/dist/utils/parse-highlight-range.js.map +1 -0
- package/dist/utils/parse-highlight-range.test.d.ts +2 -0
- package/dist/utils/parse-highlight-range.test.d.ts.map +1 -0
- package/dist/utils/parse-highlight-range.test.js +32 -0
- package/dist/utils/parse-highlight-range.test.js.map +1 -0
- package/dist/utils/schema-renderer.d.ts +38 -0
- package/dist/utils/schema-renderer.d.ts.map +1 -0
- package/dist/utils/schema-renderer.js +115 -0
- package/dist/utils/schema-renderer.js.map +1 -0
- package/dist/utils/schema-renderer.test.d.ts +2 -0
- package/dist/utils/schema-renderer.test.d.ts.map +1 -0
- package/dist/utils/schema-renderer.test.js +219 -0
- package/dist/utils/schema-renderer.test.js.map +1 -0
- package/dist/utils/shiki.d.ts +20 -0
- package/dist/utils/shiki.d.ts.map +1 -0
- package/dist/utils/shiki.js +84 -0
- package/dist/utils/shiki.js.map +1 -0
- package/dist/utils/sidebar-helpers.d.ts +10 -0
- package/dist/utils/sidebar-helpers.d.ts.map +1 -0
- package/dist/utils/sidebar-helpers.js +14 -0
- package/dist/utils/sidebar-helpers.js.map +1 -0
- package/dist/utils/theme-css.d.ts +21 -0
- package/dist/utils/theme-css.d.ts.map +1 -0
- package/dist/utils/theme-css.js +77 -0
- package/dist/utils/theme-css.js.map +1 -0
- package/dist/utils/theme-helpers.d.ts +28 -0
- package/dist/utils/theme-helpers.d.ts.map +1 -0
- package/dist/utils/theme-helpers.js +55 -0
- package/dist/utils/theme-helpers.js.map +1 -0
- package/dist/utils/toc-helpers.d.ts +12 -0
- package/dist/utils/toc-helpers.d.ts.map +1 -0
- package/dist/utils/toc-helpers.js +9 -0
- package/dist/utils/toc-helpers.js.map +1 -0
- package/package.json +68 -0
- package/src/components/ApiAuth.astro +116 -0
- package/src/components/ApiEndpoint.astro +75 -0
- package/src/components/ApiNavigation.astro +110 -0
- package/src/components/ApiParameters.astro +204 -0
- package/src/components/ApiResponse.astro +144 -0
- package/src/components/Callout.astro +54 -0
- package/src/components/Card.astro +46 -0
- package/src/components/CodeBlock.astro +142 -0
- package/src/components/CodeBlockGroup.astro +196 -0
- package/src/components/CodeTabs.astro +53 -0
- package/src/components/Footer.astro +41 -0
- package/src/components/Header.astro +80 -0
- package/src/components/Sidebar.astro +117 -0
- package/src/components/TabItem.astro +24 -0
- package/src/components/TableOfContents.astro +111 -0
- package/src/components/Tabs.astro +185 -0
- package/src/islands/CodeTabs.tsx +212 -0
- package/src/islands/CopyButton.tsx +101 -0
- package/src/islands/SearchPalette.tsx +307 -0
- package/src/islands/SearchResults.tsx +301 -0
- package/src/islands/ThemeToggle.tsx +107 -0
- package/src/layouts/ApiReferencePage.astro +239 -0
- package/src/layouts/DocPage.astro +199 -0
- package/src/layouts/DocPage.test.ts +183 -0
- package/src/layouts/LandingPage.astro +143 -0
- package/src/lib/utils.ts +13 -0
- package/src/styles/global.css +241 -0
- package/src/utils/parse-highlight-range.test.ts +40 -0
- package/src/utils/parse-highlight-range.ts +41 -0
- package/src/utils/schema-renderer.test.ts +269 -0
- package/src/utils/schema-renderer.ts +152 -0
- package/src/utils/shiki.ts +99 -0
- package/src/utils/sidebar-helpers.ts +24 -0
- package/src/utils/theme-css.ts +101 -0
- package/src/utils/theme-helpers.ts +59 -0
- package/src/utils/toc-helpers.ts +11 -0
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for the schema-renderer utility.
|
|
3
|
+
* Covers renderTypeString() and flattenSchemaProperties().
|
|
4
|
+
*/
|
|
5
|
+
import { describe, it, expect } from "vitest";
|
|
6
|
+
import type { ApiSchema } from "@specglass/core";
|
|
7
|
+
import { renderTypeString, flattenSchemaProperties } from "./schema-renderer";
|
|
8
|
+
|
|
9
|
+
describe("renderTypeString", () => {
|
|
10
|
+
it("returns 'unknown' for undefined schema", () => {
|
|
11
|
+
expect(renderTypeString(undefined)).toBe("unknown");
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it("renders simple string type", () => {
|
|
15
|
+
expect(renderTypeString({ type: "string" })).toBe("string");
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it("renders integer type", () => {
|
|
19
|
+
expect(renderTypeString({ type: "integer" })).toBe("integer");
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it("renders boolean type", () => {
|
|
23
|
+
expect(renderTypeString({ type: "boolean" })).toBe("boolean");
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("renders type with format", () => {
|
|
27
|
+
expect(renderTypeString({ type: "string", format: "uuid" })).toBe(
|
|
28
|
+
"string (uuid)",
|
|
29
|
+
);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("renders type with date-time format", () => {
|
|
33
|
+
expect(renderTypeString({ type: "string", format: "date-time" })).toBe(
|
|
34
|
+
"string (date-time)",
|
|
35
|
+
);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("renders array type with items", () => {
|
|
39
|
+
expect(
|
|
40
|
+
renderTypeString({ type: "array", items: { type: "string" } }),
|
|
41
|
+
).toBe("array<string>");
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("renders nested array type", () => {
|
|
45
|
+
expect(
|
|
46
|
+
renderTypeString({
|
|
47
|
+
type: "array",
|
|
48
|
+
items: { type: "array", items: { type: "integer" } },
|
|
49
|
+
}),
|
|
50
|
+
).toBe("array<array<integer>>");
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("renders enum type", () => {
|
|
54
|
+
expect(
|
|
55
|
+
renderTypeString({ type: "string", enum: ["active", "inactive"] }),
|
|
56
|
+
).toBe("string (enum)");
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("renders enum without explicit type defaults to string", () => {
|
|
60
|
+
expect(renderTypeString({ enum: ["a", "b", "c"] })).toBe("string (enum)");
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("renders $ref — extracts component name", () => {
|
|
64
|
+
expect(
|
|
65
|
+
renderTypeString({ ref: "#/components/schemas/Pet" }),
|
|
66
|
+
).toBe("Pet");
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("renders $ref with deep path", () => {
|
|
70
|
+
expect(
|
|
71
|
+
renderTypeString({ ref: "#/components/schemas/models/UserProfile" }),
|
|
72
|
+
).toBe("UserProfile");
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("renders nullable type", () => {
|
|
76
|
+
expect(renderTypeString({ type: "string", nullable: true })).toBe(
|
|
77
|
+
"string | null",
|
|
78
|
+
);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("renders allOf composition", () => {
|
|
82
|
+
const schema: ApiSchema = {
|
|
83
|
+
allOf: [{ ref: "#/components/schemas/Base" }, { type: "object" }],
|
|
84
|
+
};
|
|
85
|
+
expect(renderTypeString(schema)).toBe("Base & object");
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("renders oneOf composition", () => {
|
|
89
|
+
const schema: ApiSchema = {
|
|
90
|
+
oneOf: [{ type: "string" }, { type: "integer" }],
|
|
91
|
+
};
|
|
92
|
+
expect(renderTypeString(schema)).toBe("string | integer");
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("renders anyOf composition", () => {
|
|
96
|
+
const schema: ApiSchema = {
|
|
97
|
+
anyOf: [{ type: "string" }, { type: "boolean" }],
|
|
98
|
+
};
|
|
99
|
+
expect(renderTypeString(schema)).toBe("string | boolean");
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("renders object type", () => {
|
|
103
|
+
expect(renderTypeString({ type: "object" })).toBe("object");
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("handles schema with no type", () => {
|
|
107
|
+
expect(renderTypeString({})).toBe("unknown");
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
describe("flattenSchemaProperties", () => {
|
|
112
|
+
it("returns empty array for undefined schema", () => {
|
|
113
|
+
expect(flattenSchemaProperties(undefined)).toEqual([]);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it("returns empty array for schema without properties", () => {
|
|
117
|
+
expect(flattenSchemaProperties({ type: "string" })).toEqual([]);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it("flattens simple properties", () => {
|
|
121
|
+
const schema: ApiSchema = {
|
|
122
|
+
type: "object",
|
|
123
|
+
properties: {
|
|
124
|
+
name: { type: "string", description: "User name" },
|
|
125
|
+
age: { type: "integer" },
|
|
126
|
+
},
|
|
127
|
+
required: ["name"],
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
const props = flattenSchemaProperties(schema);
|
|
131
|
+
expect(props).toHaveLength(2);
|
|
132
|
+
expect(props[0]).toEqual({
|
|
133
|
+
name: "name",
|
|
134
|
+
type: "string",
|
|
135
|
+
required: true,
|
|
136
|
+
description: "User name",
|
|
137
|
+
depth: 0,
|
|
138
|
+
enumValues: undefined,
|
|
139
|
+
});
|
|
140
|
+
expect(props[1]).toEqual({
|
|
141
|
+
name: "age",
|
|
142
|
+
type: "integer",
|
|
143
|
+
required: false,
|
|
144
|
+
description: undefined,
|
|
145
|
+
depth: 0,
|
|
146
|
+
enumValues: undefined,
|
|
147
|
+
});
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it("marks required fields correctly", () => {
|
|
151
|
+
const schema: ApiSchema = {
|
|
152
|
+
type: "object",
|
|
153
|
+
properties: {
|
|
154
|
+
id: { type: "integer" },
|
|
155
|
+
email: { type: "string" },
|
|
156
|
+
},
|
|
157
|
+
required: ["id", "email"],
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
const props = flattenSchemaProperties(schema);
|
|
161
|
+
expect(props.every((p) => p.required)).toBe(true);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it("accepts external requiredFields parameter", () => {
|
|
165
|
+
const schema: ApiSchema = {
|
|
166
|
+
type: "object",
|
|
167
|
+
properties: {
|
|
168
|
+
foo: { type: "string" },
|
|
169
|
+
bar: { type: "string" },
|
|
170
|
+
},
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
const props = flattenSchemaProperties(schema, ["foo"]);
|
|
174
|
+
expect(props[0]!.required).toBe(true);
|
|
175
|
+
expect(props[1]!.required).toBe(false);
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it("expands nested object properties to depth 1", () => {
|
|
179
|
+
const schema: ApiSchema = {
|
|
180
|
+
type: "object",
|
|
181
|
+
properties: {
|
|
182
|
+
address: {
|
|
183
|
+
type: "object",
|
|
184
|
+
properties: {
|
|
185
|
+
street: { type: "string", description: "Street name" },
|
|
186
|
+
city: { type: "string" },
|
|
187
|
+
},
|
|
188
|
+
required: ["street"],
|
|
189
|
+
},
|
|
190
|
+
},
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
const props = flattenSchemaProperties(schema);
|
|
194
|
+
expect(props).toHaveLength(3);
|
|
195
|
+
expect(props[0]!.name).toBe("address");
|
|
196
|
+
expect(props[0]!.depth).toBe(0);
|
|
197
|
+
expect(props[1]!.name).toBe("street");
|
|
198
|
+
expect(props[1]!.depth).toBe(1);
|
|
199
|
+
expect(props[1]!.required).toBe(true);
|
|
200
|
+
expect(props[2]!.name).toBe("city");
|
|
201
|
+
expect(props[2]!.depth).toBe(1);
|
|
202
|
+
expect(props[2]!.required).toBe(false);
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
it("expands array item properties to depth 1", () => {
|
|
206
|
+
const schema: ApiSchema = {
|
|
207
|
+
type: "object",
|
|
208
|
+
properties: {
|
|
209
|
+
items: {
|
|
210
|
+
type: "array",
|
|
211
|
+
items: {
|
|
212
|
+
type: "object",
|
|
213
|
+
properties: {
|
|
214
|
+
id: { type: "integer" },
|
|
215
|
+
label: { type: "string" },
|
|
216
|
+
},
|
|
217
|
+
required: ["id"],
|
|
218
|
+
},
|
|
219
|
+
},
|
|
220
|
+
},
|
|
221
|
+
};
|
|
222
|
+
|
|
223
|
+
const props = flattenSchemaProperties(schema);
|
|
224
|
+
expect(props).toHaveLength(3);
|
|
225
|
+
expect(props[0]!.name).toBe("items");
|
|
226
|
+
expect(props[0]!.depth).toBe(0);
|
|
227
|
+
expect(props[1]!.name).toBe("id");
|
|
228
|
+
expect(props[1]!.depth).toBe(1);
|
|
229
|
+
expect(props[1]!.required).toBe(true);
|
|
230
|
+
expect(props[2]!.name).toBe("label");
|
|
231
|
+
expect(props[2]!.depth).toBe(1);
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
it("includes enum values in flattened properties", () => {
|
|
235
|
+
const schema: ApiSchema = {
|
|
236
|
+
type: "object",
|
|
237
|
+
properties: {
|
|
238
|
+
status: {
|
|
239
|
+
type: "string",
|
|
240
|
+
enum: ["active", "inactive", "pending"],
|
|
241
|
+
description: "Account status",
|
|
242
|
+
},
|
|
243
|
+
},
|
|
244
|
+
};
|
|
245
|
+
|
|
246
|
+
const props = flattenSchemaProperties(schema);
|
|
247
|
+
expect(props).toHaveLength(1);
|
|
248
|
+
expect(props[0]!.enumValues).toEqual(["active", "inactive", "pending"]);
|
|
249
|
+
expect(props[0]!.type).toBe("string (enum)");
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
it("respects maxDepth=0 — no expansion", () => {
|
|
253
|
+
const schema: ApiSchema = {
|
|
254
|
+
type: "object",
|
|
255
|
+
properties: {
|
|
256
|
+
nested: {
|
|
257
|
+
type: "object",
|
|
258
|
+
properties: {
|
|
259
|
+
inner: { type: "string" },
|
|
260
|
+
},
|
|
261
|
+
},
|
|
262
|
+
},
|
|
263
|
+
};
|
|
264
|
+
|
|
265
|
+
const props = flattenSchemaProperties(schema, undefined, 0);
|
|
266
|
+
expect(props).toHaveLength(1);
|
|
267
|
+
expect(props[0]!.name).toBe("nested");
|
|
268
|
+
});
|
|
269
|
+
});
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared utility for rendering ApiSchema objects into displayable
|
|
3
|
+
* type strings and processing nested property structures.
|
|
4
|
+
*
|
|
5
|
+
* Used by ApiParameters.astro and ApiResponse.astro components.
|
|
6
|
+
*
|
|
7
|
+
* @module
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { ApiSchema } from "@specglass/core";
|
|
11
|
+
|
|
12
|
+
/** A flattened property row for display in parameter/schema tables */
|
|
13
|
+
export interface SchemaProperty {
|
|
14
|
+
name: string;
|
|
15
|
+
type: string;
|
|
16
|
+
required: boolean;
|
|
17
|
+
description?: string;
|
|
18
|
+
depth: number;
|
|
19
|
+
enumValues?: string[];
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Render an ApiSchema into a human-readable type string.
|
|
24
|
+
*
|
|
25
|
+
* Examples:
|
|
26
|
+
* - `{ type: "string" }` → `"string"`
|
|
27
|
+
* - `{ type: "string", format: "uuid" }` → `"string (uuid)"`
|
|
28
|
+
* - `{ type: "array", items: { type: "string" } }` → `"array<string>"`
|
|
29
|
+
* - `{ type: "object" }` → `"object"`
|
|
30
|
+
* - `{ ref: "#/components/schemas/Pet" }` → `"Pet"`
|
|
31
|
+
* - `{ enum: ["a", "b"] }` → `"string (enum)"`
|
|
32
|
+
* - `{ allOf: [...] }` → `"allOf<...>"`
|
|
33
|
+
* - `{ oneOf: [...] }` → `"oneOf<...>"`
|
|
34
|
+
*/
|
|
35
|
+
export function renderTypeString(schema: ApiSchema | undefined): string {
|
|
36
|
+
if (!schema) return "unknown";
|
|
37
|
+
|
|
38
|
+
// $ref display — extract the component name from the path
|
|
39
|
+
if (schema.ref) {
|
|
40
|
+
const parts = schema.ref.split("/");
|
|
41
|
+
return parts[parts.length - 1] || schema.ref;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Composition types
|
|
45
|
+
if (schema.allOf && schema.allOf.length > 0) {
|
|
46
|
+
const inner = schema.allOf.map((s) => renderTypeString(s)).join(" & ");
|
|
47
|
+
return inner;
|
|
48
|
+
}
|
|
49
|
+
if (schema.oneOf && schema.oneOf.length > 0) {
|
|
50
|
+
const inner = schema.oneOf.map((s) => renderTypeString(s)).join(" | ");
|
|
51
|
+
return inner;
|
|
52
|
+
}
|
|
53
|
+
if (schema.anyOf && schema.anyOf.length > 0) {
|
|
54
|
+
const inner = schema.anyOf.map((s) => renderTypeString(s)).join(" | ");
|
|
55
|
+
return inner;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Enum
|
|
59
|
+
if (schema.enum && schema.enum.length > 0) {
|
|
60
|
+
const baseType = schema.type || "string";
|
|
61
|
+
return `${baseType} (enum)`;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Array
|
|
65
|
+
if (schema.type === "array" && schema.items) {
|
|
66
|
+
const itemType = renderTypeString(schema.items);
|
|
67
|
+
return `array<${itemType}>`;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Nullable
|
|
71
|
+
const base = schema.type || "unknown";
|
|
72
|
+
const format = schema.format ? ` (${schema.format})` : "";
|
|
73
|
+
const nullable = schema.nullable ? " | null" : "";
|
|
74
|
+
|
|
75
|
+
return `${base}${format}${nullable}`;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Flatten an ApiSchema's nested properties into a list of SchemaProperty rows,
|
|
80
|
+
* suitable for rendering in a table. Only expands 1 level deep for MVP.
|
|
81
|
+
*/
|
|
82
|
+
export function flattenSchemaProperties(
|
|
83
|
+
schema: ApiSchema | undefined,
|
|
84
|
+
requiredFields?: string[],
|
|
85
|
+
maxDepth: number = 1,
|
|
86
|
+
): SchemaProperty[] {
|
|
87
|
+
if (!schema) return [];
|
|
88
|
+
|
|
89
|
+
const properties: SchemaProperty[] = [];
|
|
90
|
+
const required = new Set(requiredFields ?? schema.required ?? []);
|
|
91
|
+
|
|
92
|
+
if (schema.properties) {
|
|
93
|
+
for (const [name, propSchema] of Object.entries(schema.properties)) {
|
|
94
|
+
properties.push({
|
|
95
|
+
name,
|
|
96
|
+
type: renderTypeString(propSchema),
|
|
97
|
+
required: required.has(name),
|
|
98
|
+
description: propSchema.description,
|
|
99
|
+
depth: 0,
|
|
100
|
+
enumValues:
|
|
101
|
+
propSchema.enum?.map((v) => String(v)),
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
// Expand one level of nested object properties
|
|
105
|
+
if (
|
|
106
|
+
maxDepth > 0 &&
|
|
107
|
+
propSchema.type === "object" &&
|
|
108
|
+
propSchema.properties
|
|
109
|
+
) {
|
|
110
|
+
const nestedRequired = new Set(propSchema.required ?? []);
|
|
111
|
+
for (const [nestedName, nestedSchema] of Object.entries(
|
|
112
|
+
propSchema.properties,
|
|
113
|
+
)) {
|
|
114
|
+
properties.push({
|
|
115
|
+
name: nestedName,
|
|
116
|
+
type: renderTypeString(nestedSchema),
|
|
117
|
+
required: nestedRequired.has(nestedName),
|
|
118
|
+
description: nestedSchema.description,
|
|
119
|
+
depth: 1,
|
|
120
|
+
enumValues:
|
|
121
|
+
nestedSchema.enum?.map((v) => String(v)),
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Expand array item properties
|
|
127
|
+
if (
|
|
128
|
+
maxDepth > 0 &&
|
|
129
|
+
propSchema.type === "array" &&
|
|
130
|
+
propSchema.items?.type === "object" &&
|
|
131
|
+
propSchema.items?.properties
|
|
132
|
+
) {
|
|
133
|
+
const nestedRequired = new Set(propSchema.items.required ?? []);
|
|
134
|
+
for (const [nestedName, nestedSchema] of Object.entries(
|
|
135
|
+
propSchema.items.properties,
|
|
136
|
+
)) {
|
|
137
|
+
properties.push({
|
|
138
|
+
name: nestedName,
|
|
139
|
+
type: renderTypeString(nestedSchema),
|
|
140
|
+
required: nestedRequired.has(nestedName),
|
|
141
|
+
description: nestedSchema.description,
|
|
142
|
+
depth: 1,
|
|
143
|
+
enumValues:
|
|
144
|
+
nestedSchema.enum?.map((v) => String(v)),
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
return properties;
|
|
152
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared Shiki highlighter utility for CodeBlock components.
|
|
3
|
+
*
|
|
4
|
+
* Uses a module-level cached highlighter instance so we don't
|
|
5
|
+
* pay the startup cost on every render. Common languages are
|
|
6
|
+
* pre-loaded at initialization; additional languages are loaded
|
|
7
|
+
* on demand. Output uses `defaultColor: false` which emits CSS
|
|
8
|
+
* variables (--shiki-light, --shiki-dark) instead of hardcoded
|
|
9
|
+
* inline colors — matching the fenced code block rendering path.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { createHighlighter, type Highlighter } from "shiki";
|
|
13
|
+
|
|
14
|
+
let highlighter: Highlighter | null = null;
|
|
15
|
+
|
|
16
|
+
export interface HighlightOptions {
|
|
17
|
+
/** Line numbers to highlight (1-indexed) */
|
|
18
|
+
highlightLines?: number[];
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Common language shorthand aliases that Shiki may not auto-resolve.
|
|
23
|
+
*/
|
|
24
|
+
const LANG_ALIASES: Record<string, string> = {
|
|
25
|
+
ts: "typescript",
|
|
26
|
+
js: "javascript",
|
|
27
|
+
py: "python",
|
|
28
|
+
rb: "ruby",
|
|
29
|
+
sh: "bash",
|
|
30
|
+
shell: "bash",
|
|
31
|
+
yml: "yaml",
|
|
32
|
+
md: "markdown",
|
|
33
|
+
mdx: "markdown",
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Syntax-highlight `code` using Shiki dual-theme (github-light / github-dark).
|
|
38
|
+
* Returns raw HTML string with CSS variable tokens — ready for `set:html`.
|
|
39
|
+
*/
|
|
40
|
+
export async function highlight(
|
|
41
|
+
code: string,
|
|
42
|
+
lang: string,
|
|
43
|
+
options?: HighlightOptions,
|
|
44
|
+
): Promise<string> {
|
|
45
|
+
if (!highlighter) {
|
|
46
|
+
highlighter = await createHighlighter({
|
|
47
|
+
themes: ["github-light", "github-dark"],
|
|
48
|
+
langs: [
|
|
49
|
+
"typescript",
|
|
50
|
+
"javascript",
|
|
51
|
+
"python",
|
|
52
|
+
"bash",
|
|
53
|
+
"json",
|
|
54
|
+
"yaml",
|
|
55
|
+
"html",
|
|
56
|
+
"css",
|
|
57
|
+
"markdown",
|
|
58
|
+
"tsx",
|
|
59
|
+
"jsx",
|
|
60
|
+
"ruby",
|
|
61
|
+
"go",
|
|
62
|
+
"rust",
|
|
63
|
+
"sql",
|
|
64
|
+
"graphql",
|
|
65
|
+
"diff",
|
|
66
|
+
"text",
|
|
67
|
+
],
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Normalize common aliases
|
|
72
|
+
lang = LANG_ALIASES[lang] ?? lang;
|
|
73
|
+
|
|
74
|
+
// Load language on demand if not already loaded
|
|
75
|
+
const loaded = highlighter.getLoadedLanguages();
|
|
76
|
+
if (!loaded.includes(lang as never)) {
|
|
77
|
+
try {
|
|
78
|
+
await highlighter.loadLanguage(lang as never);
|
|
79
|
+
} catch {
|
|
80
|
+
// Unknown language — fall back to plaintext
|
|
81
|
+
lang = "text";
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Build decorations for line highlighting
|
|
86
|
+
const lines = code.split("\n");
|
|
87
|
+
const decorations = options?.highlightLines?.map((line) => ({
|
|
88
|
+
start: { line: line - 1, character: 0 },
|
|
89
|
+
end: { line: line - 1, character: lines[line - 1]?.length ?? 0 },
|
|
90
|
+
properties: { class: "highlighted" },
|
|
91
|
+
}));
|
|
92
|
+
|
|
93
|
+
return highlighter.codeToHtml(code, {
|
|
94
|
+
lang,
|
|
95
|
+
themes: { light: "github-light", dark: "github-dark" },
|
|
96
|
+
defaultColor: false,
|
|
97
|
+
...(decorations?.length ? { decorations } : {}),
|
|
98
|
+
});
|
|
99
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sidebar navigation helpers — shared logic used by both
|
|
3
|
+
* `Sidebar.astro` and its unit tests.
|
|
4
|
+
*/
|
|
5
|
+
import type { NavItem } from "@specglass/core";
|
|
6
|
+
|
|
7
|
+
/** Filter out hidden items from a navigation list. */
|
|
8
|
+
export function filterVisibleItems(items: NavItem[]): NavItem[] {
|
|
9
|
+
return items.filter((item) => !item.hidden);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** Check if this item or any descendant is the current page. */
|
|
13
|
+
export function isActiveOrAncestor(
|
|
14
|
+
item: NavItem,
|
|
15
|
+
currentSlug: string,
|
|
16
|
+
): boolean {
|
|
17
|
+
if (item.type === "page" && item.slug === currentSlug) return true;
|
|
18
|
+
if (item.children) {
|
|
19
|
+
return item.children.some((child) =>
|
|
20
|
+
isActiveOrAncestor(child, currentSlug),
|
|
21
|
+
);
|
|
22
|
+
}
|
|
23
|
+
return false;
|
|
24
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import type { SpecglassConfig } from "@specglass/core";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* CSS custom property mapping from theme config.
|
|
5
|
+
*
|
|
6
|
+
* Maps config values to CSS custom properties that override
|
|
7
|
+
* the Tailwind @theme design tokens. This implements the config-level
|
|
8
|
+
* tier of the three-tier customization system.
|
|
9
|
+
*
|
|
10
|
+
* The generated CSS is injected as a <style> block in the layout <head>,
|
|
11
|
+
* ensuring config values take precedence over @theme defaults.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/** Properties that apply globally (brand colors, fonts) — always :root */
|
|
15
|
+
const ROOT_CSS_MAP: Array<{
|
|
16
|
+
configPath: (theme: NonNullable<SpecglassConfig["theme"]>) => string | undefined;
|
|
17
|
+
cssProperty: string;
|
|
18
|
+
}> = [
|
|
19
|
+
{ configPath: (t) => t.primaryColor ?? t.colors?.primary, cssProperty: "--color-primary" },
|
|
20
|
+
{ configPath: (t) => t.accentColor ?? t.colors?.accent, cssProperty: "--color-primary-light" },
|
|
21
|
+
{ configPath: (t) => t.fontFamily, cssProperty: "--font-sans" },
|
|
22
|
+
{ configPath: (t) => t.codeFontFamily, cssProperty: "--font-mono" },
|
|
23
|
+
];
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Surface color overrides — support light/dark pairs.
|
|
27
|
+
*
|
|
28
|
+
* Each entry maps a config color key to the CSS properties for light and dark.
|
|
29
|
+
* - `string` value → dark-only override (backward compat)
|
|
30
|
+
* - `{ light, dark }` → explicit pair, light goes to base token, dark to -dark variant
|
|
31
|
+
*/
|
|
32
|
+
type ColorValue = string | { light?: string; dark?: string } | undefined;
|
|
33
|
+
|
|
34
|
+
interface ColorMapping {
|
|
35
|
+
configPath: (theme: NonNullable<SpecglassConfig["theme"]>) => ColorValue;
|
|
36
|
+
lightProperty: string;
|
|
37
|
+
darkProperty: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const COLOR_PAIR_MAP: ColorMapping[] = [
|
|
41
|
+
{ configPath: (t) => t.colors?.background, lightProperty: "--color-surface", darkProperty: "--color-surface-dark" },
|
|
42
|
+
{ configPath: (t) => t.colors?.foreground, lightProperty: "--color-text", darkProperty: "--color-text-dark" },
|
|
43
|
+
{ configPath: (t) => t.colors?.muted, lightProperty: "--color-text-muted", darkProperty: "--color-text-muted-dark" },
|
|
44
|
+
{ configPath: (t) => t.colors?.border, lightProperty: "--color-border", darkProperty: "--color-border-dark" },
|
|
45
|
+
];
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Generates a CSS string with custom property overrides from the theme config.
|
|
49
|
+
*
|
|
50
|
+
* Brand tokens (primaryColor, fonts) are set at `:root` and apply to both modes.
|
|
51
|
+
* Surface colors support two formats:
|
|
52
|
+
* - `"#0a0a0f"` (string) → sets `-dark` variant only (backward compat)
|
|
53
|
+
* - `{ light: "#fff", dark: "#000" }` → sets both base and `-dark` variants
|
|
54
|
+
*
|
|
55
|
+
* Returns an empty string if no theme config values are set,
|
|
56
|
+
* allowing the @theme defaults to apply.
|
|
57
|
+
*
|
|
58
|
+
* @example
|
|
59
|
+
* ```ts
|
|
60
|
+
* const css = generateThemeCSS(config);
|
|
61
|
+
* // String value: ":root { --color-surface-dark: #0a0a0f; }"
|
|
62
|
+
* // Pair value: ":root { --color-surface: #fff; --color-surface-dark: #0a0a0f; }"
|
|
63
|
+
* ```
|
|
64
|
+
*/
|
|
65
|
+
export function generateThemeCSS(config: SpecglassConfig): string {
|
|
66
|
+
const theme = config.theme;
|
|
67
|
+
if (!theme) return "";
|
|
68
|
+
|
|
69
|
+
const overrides: string[] = [];
|
|
70
|
+
|
|
71
|
+
// Brand / font overrides → always :root
|
|
72
|
+
for (const mapping of ROOT_CSS_MAP) {
|
|
73
|
+
const value = mapping.configPath(theme);
|
|
74
|
+
if (value !== undefined) {
|
|
75
|
+
overrides.push(` ${mapping.cssProperty}: ${value};`);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Surface color overrides → light/dark pair or dark-only
|
|
80
|
+
for (const mapping of COLOR_PAIR_MAP) {
|
|
81
|
+
const value = mapping.configPath(theme);
|
|
82
|
+
if (value === undefined) continue;
|
|
83
|
+
|
|
84
|
+
if (typeof value === "string") {
|
|
85
|
+
// Backward compat: string = dark-only override
|
|
86
|
+
overrides.push(` ${mapping.darkProperty}: ${value};`);
|
|
87
|
+
} else {
|
|
88
|
+
// Explicit pair
|
|
89
|
+
if (value.light) {
|
|
90
|
+
overrides.push(` ${mapping.lightProperty}: ${value.light};`);
|
|
91
|
+
}
|
|
92
|
+
if (value.dark) {
|
|
93
|
+
overrides.push(` ${mapping.darkProperty}: ${value.dark};`);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (overrides.length === 0) return "";
|
|
99
|
+
|
|
100
|
+
return `:root {\n${overrides.join("\n")}\n}`;
|
|
101
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Theme utilities — pure functions for theme storage and application.
|
|
3
|
+
*
|
|
4
|
+
* Extracted to a shared utility for testability (Story 3.1 pattern).
|
|
5
|
+
* Used by ThemeToggle.tsx. The FOUC-prevention inline script reimplements
|
|
6
|
+
* the same logic inline because `is:inline` scripts cannot import modules.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export type Theme = "dark" | "light";
|
|
10
|
+
|
|
11
|
+
/** localStorage key for theme preference */
|
|
12
|
+
export const THEME_STORAGE_KEY = "specglass-theme";
|
|
13
|
+
|
|
14
|
+
/** Default theme when no preference is stored */
|
|
15
|
+
export const DEFAULT_THEME: Theme = "dark";
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Read the stored theme preference from localStorage.
|
|
19
|
+
* Returns null if no preference is stored or localStorage is unavailable.
|
|
20
|
+
*/
|
|
21
|
+
export function getStoredTheme(): Theme | null {
|
|
22
|
+
try {
|
|
23
|
+
const stored = localStorage.getItem(THEME_STORAGE_KEY);
|
|
24
|
+
if (stored === "dark" || stored === "light") {
|
|
25
|
+
return stored;
|
|
26
|
+
}
|
|
27
|
+
return null;
|
|
28
|
+
} catch {
|
|
29
|
+
// localStorage unavailable (SSR, privacy mode, etc.)
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Determine the effective theme given a stored preference.
|
|
36
|
+
* Falls back to DEFAULT_THEME ("dark") when no preference exists.
|
|
37
|
+
*/
|
|
38
|
+
export function getEffectiveTheme(stored: Theme | null): Theme {
|
|
39
|
+
return stored ?? DEFAULT_THEME;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Apply a theme by toggling the .dark class on <html>.
|
|
44
|
+
* Also persists the preference to localStorage.
|
|
45
|
+
*/
|
|
46
|
+
export function applyTheme(theme: Theme): void {
|
|
47
|
+
const root = document.documentElement;
|
|
48
|
+
if (theme === "dark") {
|
|
49
|
+
root.classList.add("dark");
|
|
50
|
+
} else {
|
|
51
|
+
root.classList.remove("dark");
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
try {
|
|
55
|
+
localStorage.setItem(THEME_STORAGE_KEY, theme);
|
|
56
|
+
} catch {
|
|
57
|
+
// localStorage unavailable — graceful degradation
|
|
58
|
+
}
|
|
59
|
+
}
|