@hypoth-ui/docs-renderer-next 0.1.0

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.
@@ -0,0 +1,207 @@
1
+ "use client";
2
+
3
+ import type { CategoryInfo, ConformanceData, ConformanceStatus } from "@hypoth-ui/docs-core";
4
+ import { useMemo, useState } from "react";
5
+ import { CategoryFilter } from "./CategoryFilter";
6
+ import { ConformanceTable } from "./ConformanceTable";
7
+
8
+ // Placeholder data - in production, this would be loaded from the conformance report
9
+ const PLACEHOLDER_DATA: ConformanceData = {
10
+ version: "0.0.0",
11
+ generatedAt: new Date().toISOString(),
12
+ wcagVersion: "2.1",
13
+ targetLevel: "AA",
14
+ components: [
15
+ {
16
+ id: "ds-button",
17
+ name: "Button",
18
+ category: "form-controls",
19
+ status: "conformant",
20
+ wcagLevel: "AA",
21
+ automatedPassed: true,
22
+ manualAuditComplete: true,
23
+ passCount: 10,
24
+ failCount: 0,
25
+ },
26
+ {
27
+ id: "ds-input",
28
+ name: "Input",
29
+ category: "form-controls",
30
+ status: "conformant",
31
+ wcagLevel: "AA",
32
+ automatedPassed: true,
33
+ manualAuditComplete: true,
34
+ passCount: 10,
35
+ failCount: 0,
36
+ },
37
+ {
38
+ id: "ds-checkbox",
39
+ name: "Checkbox",
40
+ category: "form-controls",
41
+ status: "pending",
42
+ wcagLevel: "AA",
43
+ automatedPassed: true,
44
+ manualAuditComplete: false,
45
+ },
46
+ {
47
+ id: "ds-dialog",
48
+ name: "Dialog",
49
+ category: "overlays",
50
+ status: "conformant",
51
+ wcagLevel: "AA",
52
+ automatedPassed: true,
53
+ manualAuditComplete: true,
54
+ passCount: 12,
55
+ failCount: 0,
56
+ },
57
+ {
58
+ id: "ds-tooltip",
59
+ name: "Tooltip",
60
+ category: "overlays",
61
+ status: "partial",
62
+ wcagLevel: "AA",
63
+ automatedPassed: true,
64
+ manualAuditComplete: true,
65
+ passCount: 8,
66
+ failCount: 2,
67
+ },
68
+ ],
69
+ summary: {
70
+ total: 5,
71
+ conformant: 3,
72
+ partial: 1,
73
+ nonConformant: 0,
74
+ pending: 1,
75
+ conformancePercentage: 60,
76
+ },
77
+ };
78
+
79
+ export default function AccessibilityPage() {
80
+ const [data] = useState<ConformanceData>(PLACEHOLDER_DATA);
81
+ const [selectedCategory, setSelectedCategory] = useState<string | null>(null);
82
+ const [selectedStatus, setSelectedStatus] = useState<ConformanceStatus | null>(null);
83
+ const [searchQuery, setSearchQuery] = useState("");
84
+
85
+ // Get categories
86
+ const categories = useMemo<CategoryInfo[]>(() => {
87
+ const counts = new Map<string, number>();
88
+ for (const c of data.components) {
89
+ counts.set(c.category, (counts.get(c.category) ?? 0) + 1);
90
+ }
91
+ return Array.from(counts.entries()).map(([id, count]) => ({
92
+ id,
93
+ name: id.replace(/-/g, " ").replace(/\b\w/g, (l) => l.toUpperCase()),
94
+ count,
95
+ }));
96
+ }, [data.components]);
97
+
98
+ // Filter components
99
+ const filteredComponents = useMemo(() => {
100
+ let result = data.components;
101
+
102
+ if (selectedCategory) {
103
+ result = result.filter((c) => c.category === selectedCategory);
104
+ }
105
+
106
+ if (selectedStatus) {
107
+ result = result.filter((c) => c.status === selectedStatus);
108
+ }
109
+
110
+ if (searchQuery) {
111
+ const query = searchQuery.toLowerCase();
112
+ result = result.filter(
113
+ (c) => c.id.toLowerCase().includes(query) || c.name.toLowerCase().includes(query)
114
+ );
115
+ }
116
+
117
+ return result;
118
+ }, [data.components, selectedCategory, selectedStatus, searchQuery]);
119
+
120
+ return (
121
+ <div className="max-w-6xl mx-auto px-4 py-8">
122
+ {/* Header */}
123
+ <header className="mb-8">
124
+ <h1 className="text-3xl font-bold text-gray-900 dark:text-gray-100 mb-2">
125
+ Accessibility Conformance
126
+ </h1>
127
+ <p className="text-gray-600 dark:text-gray-400">
128
+ WCAG {data.wcagVersion} Level {data.targetLevel} conformance status for all components in
129
+ the design system.
130
+ </p>
131
+ </header>
132
+
133
+ {/* Summary cards */}
134
+ <section className="mb-8 grid grid-cols-2 md:grid-cols-5 gap-4">
135
+ <div className="rounded-lg border border-gray-200 bg-white p-4 dark:border-gray-700 dark:bg-gray-800">
136
+ <div className="text-2xl font-bold text-gray-900 dark:text-gray-100">
137
+ {data.summary.total}
138
+ </div>
139
+ <div className="text-sm text-gray-600 dark:text-gray-400">Total Components</div>
140
+ </div>
141
+ <div className="rounded-lg border border-green-200 bg-green-50 p-4 dark:border-green-800 dark:bg-green-900/20">
142
+ <div className="text-2xl font-bold text-green-700 dark:text-green-400">
143
+ {data.summary.conformant}
144
+ </div>
145
+ <div className="text-sm text-green-600 dark:text-green-400">Conformant</div>
146
+ </div>
147
+ <div className="rounded-lg border border-yellow-200 bg-yellow-50 p-4 dark:border-yellow-800 dark:bg-yellow-900/20">
148
+ <div className="text-2xl font-bold text-yellow-700 dark:text-yellow-400">
149
+ {data.summary.partial}
150
+ </div>
151
+ <div className="text-sm text-yellow-600 dark:text-yellow-400">Partial</div>
152
+ </div>
153
+ <div className="rounded-lg border border-red-200 bg-red-50 p-4 dark:border-red-800 dark:bg-red-900/20">
154
+ <div className="text-2xl font-bold text-red-700 dark:text-red-400">
155
+ {data.summary.nonConformant}
156
+ </div>
157
+ <div className="text-sm text-red-600 dark:text-red-400">Non-Conformant</div>
158
+ </div>
159
+ <div className="rounded-lg border border-gray-200 bg-gray-50 p-4 dark:border-gray-700 dark:bg-gray-800">
160
+ <div className="text-2xl font-bold text-gray-700 dark:text-gray-400">
161
+ {data.summary.pending}
162
+ </div>
163
+ <div className="text-sm text-gray-600 dark:text-gray-400">Pending Audit</div>
164
+ </div>
165
+ </section>
166
+
167
+ {/* Progress bar */}
168
+ <section className="mb-8">
169
+ <div className="flex justify-between text-sm text-gray-600 dark:text-gray-400 mb-2">
170
+ <span>Conformance Progress</span>
171
+ <span>{data.summary.conformancePercentage}%</span>
172
+ </div>
173
+ <div className="h-3 rounded-full bg-gray-200 dark:bg-gray-700 overflow-hidden">
174
+ <div
175
+ className="h-full bg-green-500 transition-all duration-300"
176
+ style={{ width: `${data.summary.conformancePercentage}%` }}
177
+ />
178
+ </div>
179
+ </section>
180
+
181
+ {/* Filters */}
182
+ <CategoryFilter
183
+ categories={categories}
184
+ selectedCategory={selectedCategory}
185
+ selectedStatus={selectedStatus}
186
+ onCategoryChange={setSelectedCategory}
187
+ onStatusChange={setSelectedStatus}
188
+ onSearchChange={setSearchQuery}
189
+ />
190
+
191
+ {/* Results count */}
192
+ <div className="mb-4 text-sm text-gray-600 dark:text-gray-400">
193
+ Showing {filteredComponents.length} of {data.components.length} components
194
+ </div>
195
+
196
+ {/* Component table */}
197
+ <ConformanceTable components={filteredComponents} />
198
+
199
+ {/* Footer info */}
200
+ <footer className="mt-8 text-sm text-gray-500 dark:text-gray-400">
201
+ <p>
202
+ Last updated: {new Date(data.generatedAt).toLocaleDateString()} • Version: {data.version}
203
+ </p>
204
+ </footer>
205
+ </div>
206
+ );
207
+ }
@@ -0,0 +1,241 @@
1
+ /**
2
+ * Search API Route
3
+ *
4
+ * Provides search functionality for the documentation site.
5
+ * Loads the pre-built search index and performs client-side filtering.
6
+ */
7
+
8
+ import { type NextRequest, NextResponse } from "next/server";
9
+ import type { SearchEntry, SearchIndex } from "@hypoth-ui/docs-core";
10
+
11
+ // Cache the search index in memory
12
+ let searchIndex: SearchIndex | null = null;
13
+ let indexLoadPromise: Promise<SearchIndex | null> | null = null;
14
+
15
+ /**
16
+ * Load the search index from the file system or CDN
17
+ */
18
+ async function loadSearchIndex(): Promise<SearchIndex | null> {
19
+ if (searchIndex) {
20
+ return searchIndex;
21
+ }
22
+
23
+ if (indexLoadPromise) {
24
+ return indexLoadPromise;
25
+ }
26
+
27
+ indexLoadPromise = (async () => {
28
+ try {
29
+ // In production, this would load from a CDN or static file
30
+ // For now, we'll use a bundled index or return mock data
31
+ const indexPath = process.env.SEARCH_INDEX_PATH || "./dist/search-index.json";
32
+
33
+ // Try to load from file system in development
34
+ if (process.env.NODE_ENV === "development") {
35
+ const fs = await import("node:fs/promises");
36
+ const path = await import("node:path");
37
+ const fullPath = path.join(process.cwd(), indexPath);
38
+
39
+ try {
40
+ const content = await fs.readFile(fullPath, "utf-8");
41
+ searchIndex = JSON.parse(content) as SearchIndex;
42
+ return searchIndex;
43
+ } catch {
44
+ // Index not found, return empty
45
+ console.warn(`Search index not found at ${fullPath}`);
46
+ }
47
+ }
48
+
49
+ // Return a minimal fallback index
50
+ searchIndex = {
51
+ version: "1.0.0",
52
+ generatedAt: new Date().toISOString(),
53
+ edition: "enterprise",
54
+ entries: [],
55
+ facets: {
56
+ categories: [],
57
+ types: [],
58
+ tags: [],
59
+ },
60
+ };
61
+
62
+ return searchIndex;
63
+ } catch (error) {
64
+ console.error("Failed to load search index:", error);
65
+ return null;
66
+ }
67
+ })();
68
+
69
+ return indexLoadPromise;
70
+ }
71
+
72
+ /**
73
+ * Search entries by query
74
+ */
75
+ function searchEntries(
76
+ entries: SearchEntry[],
77
+ query: string,
78
+ options: {
79
+ type?: "component" | "guide";
80
+ category?: string;
81
+ limit?: number;
82
+ } = {}
83
+ ): SearchEntry[] {
84
+ const { type, category, limit = 20 } = options;
85
+ const normalizedQuery = query.toLowerCase().trim();
86
+
87
+ if (!normalizedQuery) {
88
+ return [];
89
+ }
90
+
91
+ // Filter and score entries
92
+ const scored = entries
93
+ .filter((entry) => {
94
+ // Apply type filter
95
+ if (type && entry.type !== type) {
96
+ return false;
97
+ }
98
+
99
+ // Apply category filter
100
+ if (category && entry.category !== category) {
101
+ return false;
102
+ }
103
+
104
+ return true;
105
+ })
106
+ .map((entry) => {
107
+ let score = 0;
108
+
109
+ // Title match (highest weight)
110
+ const titleLower = entry.title.toLowerCase();
111
+ if (titleLower === normalizedQuery) {
112
+ score += 100;
113
+ } else if (titleLower.startsWith(normalizedQuery)) {
114
+ score += 50;
115
+ } else if (titleLower.includes(normalizedQuery)) {
116
+ score += 25;
117
+ }
118
+
119
+ // Description match
120
+ const descLower = entry.description?.toLowerCase() || "";
121
+ if (descLower.includes(normalizedQuery)) {
122
+ score += 15;
123
+ }
124
+
125
+ // Excerpt match
126
+ const excerptLower = entry.excerpt?.toLowerCase() || "";
127
+ if (excerptLower.includes(normalizedQuery)) {
128
+ score += 10;
129
+ }
130
+
131
+ // Tag match
132
+ const tagMatch = entry.tags?.some(
133
+ (tag) => tag.toLowerCase().includes(normalizedQuery) || normalizedQuery.includes(tag.toLowerCase())
134
+ );
135
+ if (tagMatch) {
136
+ score += 20;
137
+ }
138
+
139
+ return { entry, score };
140
+ })
141
+ .filter(({ score }) => score > 0)
142
+ .sort((a, b) => b.score - a.score)
143
+ .slice(0, limit)
144
+ .map(({ entry }) => entry);
145
+
146
+ return scored;
147
+ }
148
+
149
+ /**
150
+ * GET /api/search
151
+ *
152
+ * Query parameters:
153
+ * - q: Search query (required)
154
+ * - type: Filter by type (component | guide)
155
+ * - category: Filter by category
156
+ * - limit: Maximum results (default: 20)
157
+ */
158
+ export async function GET(request: NextRequest) {
159
+ const searchParams = request.nextUrl.searchParams;
160
+ const query = searchParams.get("q") || "";
161
+ const type = searchParams.get("type") as "component" | "guide" | null;
162
+ const category = searchParams.get("category");
163
+ const limit = Number.parseInt(searchParams.get("limit") || "20", 10);
164
+
165
+ if (!query.trim()) {
166
+ return NextResponse.json({
167
+ results: [],
168
+ query: "",
169
+ total: 0,
170
+ });
171
+ }
172
+
173
+ const index = await loadSearchIndex();
174
+
175
+ if (!index) {
176
+ return NextResponse.json(
177
+ { error: "Search index not available" },
178
+ { status: 503 }
179
+ );
180
+ }
181
+
182
+ const results = searchEntries(index.entries, query, {
183
+ type: type || undefined,
184
+ category: category || undefined,
185
+ limit,
186
+ });
187
+
188
+ return NextResponse.json({
189
+ results,
190
+ query,
191
+ total: results.length,
192
+ facets: index.facets,
193
+ });
194
+ }
195
+
196
+ /**
197
+ * POST /api/search
198
+ *
199
+ * Alternative endpoint for more complex queries
200
+ */
201
+ export async function POST(request: NextRequest) {
202
+ try {
203
+ const body = await request.json();
204
+ const { query = "", type, category, limit = 20 } = body;
205
+
206
+ if (!query.trim()) {
207
+ return NextResponse.json({
208
+ results: [],
209
+ query: "",
210
+ total: 0,
211
+ });
212
+ }
213
+
214
+ const index = await loadSearchIndex();
215
+
216
+ if (!index) {
217
+ return NextResponse.json(
218
+ { error: "Search index not available" },
219
+ { status: 503 }
220
+ );
221
+ }
222
+
223
+ const results = searchEntries(index.entries, query, {
224
+ type,
225
+ category,
226
+ limit,
227
+ });
228
+
229
+ return NextResponse.json({
230
+ results,
231
+ query,
232
+ total: results.length,
233
+ facets: index.facets,
234
+ });
235
+ } catch {
236
+ return NextResponse.json(
237
+ { error: "Invalid request body" },
238
+ { status: 400 }
239
+ );
240
+ }
241
+ }