@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,316 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import {
3
+ type ComponentAccessibility,
4
+ type ComponentStatus,
5
+ type ContractManifest,
6
+ type Edition,
7
+ getMinimumEdition,
8
+ isComponentAvailable,
9
+ } from "@hypoth-ui/docs-core";
10
+ import {
11
+ getEditionConfig,
12
+ loadManifestByIdFromPacks,
13
+ loadManifestsFromPacks,
14
+ resolveContentFile,
15
+ } from "../../../lib/content-resolver";
16
+
17
+ /**
18
+ * Display manifest shape used for rendering component pages
19
+ * Normalizes both ContractManifest and legacy ComponentManifest formats
20
+ */
21
+ interface DisplayManifest {
22
+ name: string;
23
+ description: string;
24
+ status: ComponentStatus;
25
+ props: Array<{ name: string; type: string; default?: string; description: string }>;
26
+ events: Array<{ name: string; description: string }>;
27
+ examples: Array<{ title: string; code: string }>;
28
+ tokensUsed: string[];
29
+ accessibility?: ComponentAccessibility;
30
+ }
31
+ import { notFound, redirect } from "next/navigation";
32
+ import { MdxRenderer } from "../../../components/mdx-renderer";
33
+ import { EditionProvider } from "../../../components/mdx/edition";
34
+ import { TokensUsed } from "../../../components/tokens-used";
35
+
36
+ interface ComponentPageProps {
37
+ params: Promise<{
38
+ id: string;
39
+ }>;
40
+ }
41
+
42
+ /**
43
+ * Get the current edition from environment or config
44
+ */
45
+ async function getCurrentEdition(): Promise<Edition> {
46
+ // Check environment variable first
47
+ const envEdition = process.env.DS_EDITION;
48
+ if (envEdition && ["core", "pro", "enterprise"].includes(envEdition)) {
49
+ return envEdition as Edition;
50
+ }
51
+
52
+ // Fall back to edition config from content resolver
53
+ try {
54
+ const config = await getEditionConfig();
55
+ return config.edition;
56
+ } catch {
57
+ return "enterprise"; // Default to enterprise (shows all)
58
+ }
59
+ }
60
+
61
+ /**
62
+ * Load contract manifests from content packs with overlay resolution
63
+ */
64
+ async function loadContractManifests(): Promise<ContractManifest[]> {
65
+ return loadManifestsFromPacks();
66
+ }
67
+
68
+ /**
69
+ * Generate static params for all components available in the current edition
70
+ */
71
+ export async function generateStaticParams() {
72
+ const edition = await getCurrentEdition();
73
+ const manifests = await loadContractManifests();
74
+
75
+ // Filter by edition
76
+ const availableComponents = manifests.filter((manifest) =>
77
+ isComponentAvailable(manifest.editions, edition)
78
+ );
79
+
80
+ return availableComponents.map((manifest) => ({
81
+ id: manifest.id,
82
+ }));
83
+ }
84
+
85
+ export async function generateMetadata({ params }: ComponentPageProps) {
86
+ const { id } = await params;
87
+
88
+ // Load manifest with overlay resolution
89
+ const manifest = await loadManifestByIdFromPacks(id);
90
+
91
+ if (!manifest) {
92
+ return { title: "Component Not Found" };
93
+ }
94
+
95
+ return {
96
+ title: manifest.name,
97
+ description: manifest.description,
98
+ };
99
+ }
100
+
101
+ export default async function ComponentPage({ params }: ComponentPageProps) {
102
+ const { id } = await params;
103
+ const edition = await getCurrentEdition();
104
+
105
+ // Try to load from contract manifests
106
+ const manifests = await loadContractManifests();
107
+ const contractManifest = manifests.find((m) => m.id === id);
108
+
109
+ // Check edition access
110
+ if (contractManifest) {
111
+ const isAvailable = isComponentAvailable(contractManifest.editions, edition);
112
+
113
+ if (!isAvailable) {
114
+ // Redirect to upgrade page
115
+ const requiredEdition = getMinimumEdition(contractManifest.editions);
116
+ redirect(`/edition-upgrade?component=${id}&from=${edition}&to=${requiredEdition}`);
117
+ }
118
+ }
119
+
120
+ // Use overlay-resolved manifest or fall back to direct lookup
121
+ const manifest = contractManifest ?? (await loadManifestByIdFromPacks(id));
122
+
123
+ if (!manifest) {
124
+ notFound();
125
+ }
126
+
127
+ // Load MDX content with overlay resolution
128
+ let mdxContent: string | null = null;
129
+ try {
130
+ const resolved = await resolveContentFile(`components/${id}.mdx`);
131
+ if (resolved) {
132
+ mdxContent = await readFile(resolved.resolvedPath, "utf-8");
133
+ }
134
+ } catch {
135
+ // No MDX file, use auto-generated content from manifest
136
+ }
137
+
138
+ // Normalize to DisplayManifest format for rendering
139
+ // Contract manifests don't have props/events/examples (those come from MDX)
140
+ const displayManifest: DisplayManifest = {
141
+ name: manifest.name,
142
+ description: manifest.description,
143
+ status: manifest.status,
144
+ props: [],
145
+ events: [],
146
+ examples: [],
147
+ tokensUsed: manifest.tokensUsed ?? [],
148
+ accessibility: manifest.accessibility,
149
+ };
150
+
151
+ return (
152
+ <EditionProvider edition={edition}>
153
+ <article className="component-page">
154
+ <header className="component-header">
155
+ <div className="component-status" data-status={displayManifest.status}>
156
+ {displayManifest.status}
157
+ </div>
158
+ <h1>{displayManifest.name}</h1>
159
+ {displayManifest.description && (
160
+ <p className="component-description">{displayManifest.description}</p>
161
+ )}
162
+ </header>
163
+
164
+ {displayManifest.tokensUsed.length > 0 && (
165
+ <TokensUsed tokens={displayManifest.tokensUsed} />
166
+ )}
167
+
168
+ {displayManifest.accessibility && (
169
+ <section className="component-accessibility">
170
+ <h2>Accessibility</h2>
171
+ <dl className="accessibility-details">
172
+ <div className="accessibility-item">
173
+ <dt>APG Pattern</dt>
174
+ <dd>
175
+ <a
176
+ href={`https://www.w3.org/WAI/ARIA/apg/patterns/${displayManifest.accessibility.apgPattern.toLowerCase().replace(/\s+/g, "-")}/`}
177
+ target="_blank"
178
+ rel="noopener noreferrer"
179
+ >
180
+ {displayManifest.accessibility.apgPattern}
181
+ </a>
182
+ </dd>
183
+ </div>
184
+ {displayManifest.accessibility.keyboard.length > 0 && (
185
+ <div className="accessibility-item">
186
+ <dt>Keyboard Interactions</dt>
187
+ <dd>
188
+ <ul className="keyboard-list">
189
+ {displayManifest.accessibility.keyboard.map((interaction) => (
190
+ <li key={interaction}>{interaction}</li>
191
+ ))}
192
+ </ul>
193
+ </dd>
194
+ </div>
195
+ )}
196
+ <div className="accessibility-item">
197
+ <dt>Screen Reader</dt>
198
+ <dd>{displayManifest.accessibility.screenReader}</dd>
199
+ </div>
200
+ {displayManifest.accessibility.ariaPatterns &&
201
+ displayManifest.accessibility.ariaPatterns.length > 0 && (
202
+ <div className="accessibility-item">
203
+ <dt>ARIA Patterns</dt>
204
+ <dd>
205
+ <ul className="aria-list">
206
+ {displayManifest.accessibility.ariaPatterns.map((pattern) => (
207
+ <li key={pattern}>
208
+ <code>{pattern}</code>
209
+ </li>
210
+ ))}
211
+ </ul>
212
+ </dd>
213
+ </div>
214
+ )}
215
+ {displayManifest.accessibility.knownLimitations &&
216
+ displayManifest.accessibility.knownLimitations.length > 0 && (
217
+ <div className="accessibility-item accessibility-item--warning">
218
+ <dt>Known Limitations</dt>
219
+ <dd>
220
+ <ul className="limitations-list">
221
+ {displayManifest.accessibility.knownLimitations.map((limitation) => (
222
+ <li key={limitation}>{limitation}</li>
223
+ ))}
224
+ </ul>
225
+ </dd>
226
+ </div>
227
+ )}
228
+ </dl>
229
+ </section>
230
+ )}
231
+
232
+ {mdxContent ? (
233
+ <MdxRenderer source={mdxContent} />
234
+ ) : (
235
+ <div className="component-auto-docs">
236
+ {/* Auto-generated documentation from manifest */}
237
+ <section>
238
+ <h2>Usage</h2>
239
+ <pre>
240
+ <code>{`<ds-${id}></ds-${id}>`}</code>
241
+ </pre>
242
+ </section>
243
+
244
+ {displayManifest.props && displayManifest.props.length > 0 && (
245
+ <section>
246
+ <h2>Properties</h2>
247
+ <table className="props-table">
248
+ <thead>
249
+ <tr>
250
+ <th>Name</th>
251
+ <th>Type</th>
252
+ <th>Default</th>
253
+ <th>Description</th>
254
+ </tr>
255
+ </thead>
256
+ <tbody>
257
+ {displayManifest.props.map((prop) => (
258
+ <tr key={prop.name}>
259
+ <td>
260
+ <code>{prop.name}</code>
261
+ </td>
262
+ <td>
263
+ <code>{prop.type}</code>
264
+ </td>
265
+ <td>{prop.default ? <code>{prop.default}</code> : "—"}</td>
266
+ <td>{prop.description}</td>
267
+ </tr>
268
+ ))}
269
+ </tbody>
270
+ </table>
271
+ </section>
272
+ )}
273
+
274
+ {displayManifest.events && displayManifest.events.length > 0 && (
275
+ <section>
276
+ <h2>Events</h2>
277
+ <table className="events-table">
278
+ <thead>
279
+ <tr>
280
+ <th>Name</th>
281
+ <th>Description</th>
282
+ </tr>
283
+ </thead>
284
+ <tbody>
285
+ {displayManifest.events.map((event) => (
286
+ <tr key={event.name}>
287
+ <td>
288
+ <code>{event.name}</code>
289
+ </td>
290
+ <td>{event.description}</td>
291
+ </tr>
292
+ ))}
293
+ </tbody>
294
+ </table>
295
+ </section>
296
+ )}
297
+
298
+ {displayManifest.examples && displayManifest.examples.length > 0 && (
299
+ <section>
300
+ <h2>Examples</h2>
301
+ {displayManifest.examples.map((example) => (
302
+ <div key={example.title} className="example">
303
+ <h3>{example.title}</h3>
304
+ <pre>
305
+ <code>{example.code}</code>
306
+ </pre>
307
+ </div>
308
+ ))}
309
+ </section>
310
+ )}
311
+ </div>
312
+ )}
313
+ </article>
314
+ </EditionProvider>
315
+ );
316
+ }
@@ -0,0 +1,76 @@
1
+ import type { Edition } from "@hypoth-ui/docs-core";
2
+ import Link from "next/link";
3
+ import { UpgradePrompt } from "../../components/upgrade/upgrade-prompt";
4
+ import { getEditionConfig } from "../../lib/content-resolver";
5
+
6
+ interface UpgradePageProps {
7
+ searchParams: Promise<{
8
+ component?: string;
9
+ from?: string;
10
+ to?: string;
11
+ }>;
12
+ }
13
+
14
+ export default async function EditionUpgradePage({ searchParams }: UpgradePageProps) {
15
+ const params = await searchParams;
16
+ const componentId = params.component;
17
+ const fromEdition = (params.from ?? "core") as Edition;
18
+ const toEdition = (params.to ?? "enterprise") as Edition;
19
+
20
+ // Load upgrade config from edition config
21
+ const config = await getEditionConfig();
22
+
23
+ return (
24
+ <div className="upgrade-page">
25
+ <UpgradePrompt
26
+ currentEdition={fromEdition}
27
+ requiredEdition={toEdition}
28
+ upgradeConfig={config.upgrade}
29
+ itemName={componentId}
30
+ componentId={componentId}
31
+ variant="full-page"
32
+ />
33
+
34
+ <div className="upgrade-actions">
35
+ <Link href="/" className="btn btn-secondary">
36
+ Back to Documentation
37
+ </Link>
38
+ </div>
39
+
40
+ <style jsx>{`
41
+ .upgrade-page {
42
+ display: flex;
43
+ flex-direction: column;
44
+ justify-content: center;
45
+ align-items: center;
46
+ min-height: 80vh;
47
+ padding: 2rem;
48
+ gap: 2rem;
49
+ }
50
+
51
+ .upgrade-actions {
52
+ display: flex;
53
+ gap: 1rem;
54
+ justify-content: center;
55
+ }
56
+
57
+ .btn {
58
+ padding: 0.75rem 1.5rem;
59
+ border-radius: 8px;
60
+ font-weight: 600;
61
+ text-decoration: none;
62
+ transition: all 0.2s;
63
+ }
64
+
65
+ .btn-secondary {
66
+ background: var(--ds-surface-tertiary, #e9ecef);
67
+ color: var(--ds-text-primary, #333);
68
+ }
69
+
70
+ .btn-secondary:hover {
71
+ background: var(--ds-surface-hover, #dee2e6);
72
+ }
73
+ `}</style>
74
+ </div>
75
+ );
76
+ }
@@ -0,0 +1,67 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { parseFrontmatter } from "@hypoth-ui/docs-core";
3
+ import { notFound } from "next/navigation";
4
+ import { MdxRenderer } from "../../../components/mdx-renderer";
5
+ import { discoverGuides, resolveContentFile } from "../../../lib/content-resolver";
6
+
7
+ interface GuidePageProps {
8
+ params: Promise<{
9
+ id: string;
10
+ }>;
11
+ }
12
+
13
+ // Generate static params for all guides from content packs
14
+ export async function generateStaticParams() {
15
+ try {
16
+ const guides = await discoverGuides();
17
+ return guides.map(({ id }) => ({ id }));
18
+ } catch {
19
+ return [{ id: "getting-started" }, { id: "theming" }];
20
+ }
21
+ }
22
+
23
+ export async function generateMetadata({ params }: GuidePageProps) {
24
+ const { id } = await params;
25
+
26
+ try {
27
+ const resolved = await resolveContentFile(`guides/${id}.mdx`);
28
+ if (!resolved) {
29
+ return { title: "Guide Not Found" };
30
+ }
31
+
32
+ const content = await readFile(resolved.resolvedPath, "utf-8");
33
+ const { frontmatter } = parseFrontmatter(content);
34
+
35
+ return {
36
+ title: frontmatter.title,
37
+ description: frontmatter.description,
38
+ };
39
+ } catch {
40
+ return { title: "Guide Not Found" };
41
+ }
42
+ }
43
+
44
+ export default async function GuidePage({ params }: GuidePageProps) {
45
+ const { id } = await params;
46
+
47
+ // Resolve guide content through overlay chain
48
+ const resolved = await resolveContentFile(`guides/${id}.mdx`);
49
+
50
+ if (!resolved) {
51
+ notFound();
52
+ }
53
+
54
+ let mdxContent: string;
55
+
56
+ try {
57
+ mdxContent = await readFile(resolved.resolvedPath, "utf-8");
58
+ } catch {
59
+ notFound();
60
+ }
61
+
62
+ return (
63
+ <article className="guide-page">
64
+ <MdxRenderer source={mdxContent} />
65
+ </article>
66
+ );
67
+ }
package/app/layout.tsx ADDED
@@ -0,0 +1,93 @@
1
+ import { applyDefaultFeatures } from "@hypoth-ui/docs-core";
2
+ import type { Metadata } from "next";
3
+ import type { ReactNode } from "react";
4
+ import { BrandedHeader } from "../components/branding/header";
5
+ import { FeedbackWidget } from "../components/feedback/feedback-widget";
6
+ import { NavSidebar } from "../components/nav-sidebar";
7
+ import { SearchInput } from "../components/search/search-input";
8
+ import { ThemeInitScript } from "../components/theme-init-script";
9
+ import { ThemeSwitcher } from "../components/theme-switcher";
10
+ import { BrandingProvider } from "../lib/branding-context";
11
+ import { getEditionConfig } from "../lib/content-resolver";
12
+ import "../styles/globals.css";
13
+
14
+ /**
15
+ * Generate metadata dynamically based on branding config
16
+ */
17
+ export async function generateMetadata(): Promise<Metadata> {
18
+ const config = await getEditionConfig();
19
+ const siteName = config.branding?.name ?? "Design System";
20
+
21
+ return {
22
+ title: {
23
+ template: `%s | ${siteName}`,
24
+ default: siteName,
25
+ },
26
+ description: `Documentation for ${siteName}`,
27
+ };
28
+ }
29
+
30
+ interface RootLayoutProps {
31
+ children: ReactNode;
32
+ }
33
+
34
+ export default async function RootLayout({ children }: RootLayoutProps) {
35
+ // Load edition config for branding
36
+ const config = await getEditionConfig();
37
+
38
+ // Apply default features to ensure all flags are present
39
+ const features = applyDefaultFeatures(config.features);
40
+
41
+ // Generate CSS custom properties for branding
42
+ const brandingStyles = `
43
+ :root {
44
+ --ds-brand-primary: ${config.branding?.primaryColor ?? "#0066cc"};
45
+ }
46
+ `;
47
+
48
+ return (
49
+ <html lang="en" data-mode="light">
50
+ <head>
51
+ <ThemeInitScript />
52
+ {/* Inject branding CSS custom properties - safe as brandingStyles is server-generated */}
53
+ {/* biome-ignore lint/security/noDangerouslySetInnerHtml: CSS is server-generated from config */}
54
+ <style dangerouslySetInnerHTML={{ __html: brandingStyles }} />
55
+ {config.branding?.favicon && <link rel="icon" href={config.branding.favicon} />}
56
+ </head>
57
+ <body>
58
+ {/* T064: Skip link for keyboard accessibility */}
59
+ <a href="#main-content" className="skip-link">
60
+ Skip to main content
61
+ </a>
62
+ <BrandingProvider
63
+ branding={config.branding}
64
+ features={config.features}
65
+ upgrade={config.upgrade}
66
+ edition={config.edition}
67
+ editionId={config.id}
68
+ editionName={config.name}
69
+ >
70
+ <div className="docs-layout">
71
+ {/* T065: Proper landmark roles - header contains navigation */}
72
+ <BrandedHeader editionName={config.name}>
73
+ {/* T056: Conditionally render SearchInput based on features.search */}
74
+ {features.search && <SearchInput />}
75
+ {/* T055: Conditionally render ThemeSwitcher based on features.darkMode */}
76
+ {features.darkMode && <ThemeSwitcher />}
77
+ </BrandedHeader>
78
+ {/* T065: Proper landmark roles - nav element for navigation */}
79
+ <nav className="docs-sidebar" aria-label="Documentation navigation">
80
+ <NavSidebar edition={config.edition} />
81
+ </nav>
82
+ {/* T065: Proper landmark roles - main content area */}
83
+ <main id="main-content" className="docs-main">
84
+ <div className="docs-content">{children}</div>
85
+ </main>
86
+ {/* T057: Conditionally render FeedbackWidget based on features.feedback */}
87
+ {features.feedback && <FeedbackWidget />}
88
+ </div>
89
+ </BrandingProvider>
90
+ </body>
91
+ </html>
92
+ );
93
+ }
package/app/page.tsx ADDED
@@ -0,0 +1,29 @@
1
+ import Link from "next/link";
2
+
3
+ export default function HomePage() {
4
+ return (
5
+ <div className="docs-home">
6
+ <h1>Design System Documentation</h1>
7
+ <p>Welcome to the design system documentation.</p>
8
+
9
+ <nav className="docs-home-nav">
10
+ <h2>Get Started</h2>
11
+ <ul>
12
+ <li>
13
+ <Link href="/guides/getting-started">Getting Started</Link>
14
+ </li>
15
+ <li>
16
+ <Link href="/guides/theming">Theming</Link>
17
+ </li>
18
+ </ul>
19
+
20
+ <h2>Components</h2>
21
+ <ul>
22
+ <li>
23
+ <Link href="/components/button">Button</Link>
24
+ </li>
25
+ </ul>
26
+ </nav>
27
+ </div>
28
+ );
29
+ }
@@ -0,0 +1,82 @@
1
+ "use client";
2
+
3
+ /**
4
+ * Branded Header Component
5
+ *
6
+ * Site header with branding, navigation, and optional features.
7
+ */
8
+
9
+ import Link from "next/link";
10
+ import type { ReactNode } from "react";
11
+ import { useBranding } from "../../lib/branding-context";
12
+ import { Logo } from "./logo";
13
+
14
+ export interface BrandedHeaderProps {
15
+ /** Additional content in the header (e.g., search, theme toggle) */
16
+ children?: ReactNode;
17
+ /** Custom class name */
18
+ className?: string;
19
+ /** Edition name to display (passed from server) */
20
+ editionName?: string;
21
+ }
22
+
23
+ export function BrandedHeader({ children, className = "", editionName }: BrandedHeaderProps) {
24
+ const { primaryColor } = useBranding();
25
+
26
+ return (
27
+ <header className={`branded-header ${className}`}>
28
+ <div className="branded-header__left">
29
+ <Link href="/" className="branded-header__logo-link">
30
+ <Logo size="medium" />
31
+ </Link>
32
+ {editionName && editionName !== "Default" && (
33
+ <span className="branded-header__edition">{editionName}</span>
34
+ )}
35
+ </div>
36
+
37
+ <div className="branded-header__right">{children}</div>
38
+
39
+ <style jsx>{`
40
+ .branded-header {
41
+ display: flex;
42
+ align-items: center;
43
+ justify-content: space-between;
44
+ padding: 0.75rem 1.5rem;
45
+ background: var(--ds-surface-primary, #fff);
46
+ border-bottom: 1px solid var(--ds-border, #e5e7eb);
47
+ position: sticky;
48
+ top: 0;
49
+ z-index: 100;
50
+ }
51
+
52
+ .branded-header__left {
53
+ display: flex;
54
+ align-items: center;
55
+ gap: 0.75rem;
56
+ }
57
+
58
+ .branded-header__logo-link {
59
+ display: flex;
60
+ align-items: center;
61
+ text-decoration: none;
62
+ color: inherit;
63
+ }
64
+
65
+ .branded-header__edition {
66
+ font-size: 0.75rem;
67
+ padding: 0.125rem 0.5rem;
68
+ border-radius: 9999px;
69
+ font-weight: 600;
70
+ background-color: var(--ds-brand-primary, ${primaryColor});
71
+ color: white;
72
+ }
73
+
74
+ .branded-header__right {
75
+ display: flex;
76
+ align-items: center;
77
+ gap: 1rem;
78
+ }
79
+ `}</style>
80
+ </header>
81
+ );
82
+ }