Skip to content

Commit fa66ba7

Browse files
committed
chore: New demo server featuring SSR
1 parent 36ebfc0 commit fa66ba7

26 files changed

+2917
-168
lines changed

dev-server/app.tsx

Lines changed: 269 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,269 @@
1+
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
import React, { createContext, Suspense, useContext, useEffect, useState } from 'react';
5+
6+
import ErrorBoundary from './error-boundary';
7+
import { defaultURLParams, formatURLParams, URLParams } from './url-params';
8+
9+
/**
10+
* App context for sharing URL parameters and page state
11+
*/
12+
export interface AppContextType {
13+
pageId?: string;
14+
urlParams: URLParams;
15+
isServer: boolean;
16+
setUrlParams: (newParams: Partial<URLParams>) => void;
17+
}
18+
19+
const AppContext = createContext<AppContextType>({
20+
pageId: undefined,
21+
urlParams: defaultURLParams,
22+
isServer: true,
23+
setUrlParams: () => {},
24+
});
25+
26+
export const useAppContext = () => useContext(AppContext);
27+
28+
/**
29+
* Props for the App shell component
30+
*/
31+
export interface AppProps {
32+
pageId?: string;
33+
urlParams: URLParams;
34+
isServer: boolean;
35+
children: React.ReactNode;
36+
}
37+
38+
/**
39+
* Check if a page is an AppLayout page (needs special handling)
40+
*/
41+
function isAppLayoutPage(pageId?: string): boolean {
42+
if (!pageId) {
43+
return false;
44+
}
45+
const appLayoutPages = [
46+
'app-layout',
47+
'content-layout',
48+
'grid-navigation-custom',
49+
'expandable-rows-test',
50+
'container/sticky-permutations',
51+
'copy-to-clipboard/scenario-split-panel',
52+
'prompt-input/simple',
53+
'funnel-analytics/static-single-page-flow',
54+
'funnel-analytics/static-multi-page-flow',
55+
'charts.test',
56+
'error-boundary/demo-async-load',
57+
'error-boundary/demo-components',
58+
];
59+
return appLayoutPages.some(match => pageId.includes(match));
60+
}
61+
62+
/**
63+
* Theme switcher component with toggles for dark mode, density, motion, etc.
64+
*/
65+
function ThemeSwitcher({
66+
urlParams,
67+
setUrlParams,
68+
}: {
69+
urlParams: URLParams;
70+
setUrlParams: (p: Partial<URLParams>) => void;
71+
}) {
72+
const switcherStyle: React.CSSProperties = {
73+
display: 'flex',
74+
gap: '12px',
75+
alignItems: 'center',
76+
fontSize: '12px',
77+
};
78+
79+
const labelStyle: React.CSSProperties = {
80+
display: 'flex',
81+
alignItems: 'center',
82+
gap: '4px',
83+
cursor: 'pointer',
84+
};
85+
86+
return (
87+
<div style={switcherStyle}>
88+
<label style={labelStyle}>
89+
<input
90+
id="visual-refresh-toggle"
91+
type="checkbox"
92+
checked={urlParams.visualRefresh}
93+
onChange={e => {
94+
const newVisualRefresh = e.target.checked;
95+
const updatedParams = { ...urlParams, visualRefresh: newVisualRefresh };
96+
// Update URL first, then reload
97+
const currentPath = window.location.pathname;
98+
const newUrl = `${currentPath}${formatURLParams(updatedParams)}`;
99+
window.location.href = newUrl;
100+
}}
101+
/>
102+
Visual refresh
103+
</label>
104+
<label style={labelStyle}>
105+
<input
106+
id="mode-toggle"
107+
type="checkbox"
108+
checked={urlParams.mode === 'dark'}
109+
onChange={e => setUrlParams({ mode: e.target.checked ? 'dark' : 'light' })}
110+
/>
111+
Dark mode
112+
</label>
113+
<label style={labelStyle}>
114+
<input
115+
id="density-toggle"
116+
type="checkbox"
117+
checked={urlParams.density === 'compact'}
118+
onChange={e => setUrlParams({ density: e.target.checked ? 'compact' : 'comfortable' })}
119+
/>
120+
Compact mode
121+
</label>
122+
<label style={labelStyle}>
123+
<input
124+
id="disabled-motion-toggle"
125+
type="checkbox"
126+
checked={urlParams.motionDisabled}
127+
onChange={e => setUrlParams({ motionDisabled: e.target.checked })}
128+
/>
129+
Disable motion
130+
</label>
131+
</div>
132+
);
133+
}
134+
135+
/**
136+
* Header component for the demo pages
137+
*/
138+
function Header({
139+
sticky,
140+
urlParams,
141+
setUrlParams,
142+
}: {
143+
sticky?: boolean;
144+
urlParams: URLParams;
145+
setUrlParams: (p: Partial<URLParams>) => void;
146+
}) {
147+
const headerStyle: React.CSSProperties = {
148+
boxSizing: 'border-box',
149+
background: '#232f3e',
150+
paddingBlockStart: '12px',
151+
paddingBlockEnd: '11px',
152+
paddingInline: '12px',
153+
fontSize: '15px',
154+
fontWeight: 700,
155+
lineHeight: '17px',
156+
display: 'flex',
157+
color: '#eee',
158+
justifyContent: 'space-between',
159+
alignItems: 'center',
160+
...(sticky && {
161+
inlineSize: '100%',
162+
zIndex: 1000,
163+
insetBlockStart: 0,
164+
position: 'sticky' as const,
165+
}),
166+
};
167+
168+
const linkStyle: React.CSSProperties = {
169+
textDecoration: 'none',
170+
color: '#eee',
171+
};
172+
173+
return (
174+
<header id="h" style={headerStyle}>
175+
<a href="/" style={linkStyle}>
176+
Demo Assets
177+
</a>
178+
<ThemeSwitcher urlParams={urlParams} setUrlParams={setUrlParams} />
179+
</header>
180+
);
181+
}
182+
183+
/**
184+
* Client-side effects for applying global styles and modes
185+
* These only run on the client after hydration
186+
*/
187+
function ClientEffects({ urlParams }: { urlParams: URLParams }) {
188+
useEffect(() => {
189+
// Apply mode (light/dark)
190+
import('@cloudscape-design/global-styles').then(({ applyMode, Mode }) => {
191+
applyMode(urlParams.mode === 'dark' ? Mode.Dark : Mode.Light);
192+
});
193+
}, [urlParams.mode]);
194+
195+
useEffect(() => {
196+
// Apply density
197+
import('@cloudscape-design/global-styles').then(({ applyDensity, Density }) => {
198+
applyDensity(urlParams.density === 'compact' ? Density.Compact : Density.Comfortable);
199+
});
200+
}, [urlParams.density]);
201+
202+
useEffect(() => {
203+
// Apply motion disabled
204+
import('@cloudscape-design/global-styles').then(({ disableMotion }) => {
205+
disableMotion(urlParams.motionDisabled);
206+
});
207+
}, [urlParams.motionDisabled]);
208+
209+
useEffect(() => {
210+
// Apply direction
211+
document.documentElement.setAttribute('dir', urlParams.direction);
212+
}, [urlParams.direction]);
213+
214+
return null;
215+
}
216+
217+
/**
218+
* App shell component that wraps demo pages
219+
* Similar to pages/app/index.tsx but for SSR
220+
*/
221+
export default function App({ pageId, urlParams: initialUrlParams, isServer, children }: AppProps) {
222+
const [urlParams, setUrlParamsState] = useState(initialUrlParams);
223+
const isAppLayout = isAppLayoutPage(pageId);
224+
const ContentTag = isAppLayout ? 'div' : 'main';
225+
226+
// Update URL params and sync to URL
227+
const setUrlParams = (newParams: Partial<URLParams>) => {
228+
const updatedParams = { ...urlParams, ...newParams };
229+
setUrlParamsState(updatedParams);
230+
231+
// Update URL without reload (except for visualRefresh which needs reload)
232+
if (!isServer && !('visualRefresh' in newParams)) {
233+
const newUrl = pageId ? `/${pageId}${formatURLParams(updatedParams)}` : `/${formatURLParams(updatedParams)}`;
234+
window.history.replaceState({}, '', newUrl);
235+
}
236+
};
237+
238+
// Header is always rendered outside the error boundary so users can still
239+
// toggle settings (like visual refresh) even when a page throws an error
240+
const header = (
241+
<Header
242+
sticky={isAppLayout && pageId !== undefined && !pageId.includes('legacy')}
243+
urlParams={urlParams}
244+
setUrlParams={setUrlParams}
245+
/>
246+
);
247+
248+
// Page content is wrapped in error boundary (client-side only)
249+
const pageContent = isServer ? children : <ErrorBoundary pageId={pageId}>{children}</ErrorBoundary>;
250+
251+
// Wrap in Suspense on client side only (not supported in React 16 SSR)
252+
const suspenseWrapped = isServer ? (
253+
pageContent
254+
) : (
255+
<Suspense fallback={<span>Loading...</span>}>{pageContent}</Suspense>
256+
);
257+
258+
return (
259+
<AppContext.Provider value={{ pageId, urlParams, isServer, setUrlParams }}>
260+
{/* Client-side effects for applying global styles */}
261+
{!isServer && <ClientEffects urlParams={urlParams} />}
262+
263+
<ContentTag>
264+
{header}
265+
{suspenseWrapped}
266+
</ContentTag>
267+
</AppContext.Provider>
268+
);
269+
}

dev-server/collect-styles.mjs

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
import { glob } from 'glob';
5+
import fs from 'node:fs';
6+
import path from 'node:path';
7+
import { fileURLToPath } from 'node:url';
8+
9+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
10+
const rootDir = path.resolve(__dirname, '..');
11+
const componentsDir = path.resolve(rootDir, 'lib/components');
12+
13+
let cachedStyles = null;
14+
15+
/**
16+
* Gets the global styles CSS from @cloudscape-design/global-styles
17+
*/
18+
function getGlobalStyles() {
19+
try {
20+
// Find the global-styles package in node_modules
21+
const globalStylesPath = path.resolve(rootDir, 'node_modules/@cloudscape-design/global-styles/index.css');
22+
return fs.readFileSync(globalStylesPath, 'utf-8');
23+
} catch (e) {
24+
console.warn('Warning: Could not read global styles:', e.message);
25+
return '';
26+
}
27+
}
28+
29+
/**
30+
* Collects all scoped CSS files from the built component library
31+
* and combines them into a single CSS string for SSR injection.
32+
* Also includes global styles from @cloudscape-design/global-styles.
33+
*/
34+
export function collectStyles() {
35+
// Return cached styles if available (for performance)
36+
if (cachedStyles !== null) {
37+
return cachedStyles;
38+
}
39+
40+
// Start with global styles
41+
const globalStyles = getGlobalStyles();
42+
43+
const cssFiles = glob.sync('**/*.scoped.css', {
44+
cwd: componentsDir,
45+
absolute: true,
46+
});
47+
48+
const componentStyles = cssFiles
49+
.map(file => {
50+
try {
51+
return fs.readFileSync(file, 'utf-8');
52+
} catch (e) {
53+
console.warn(`Warning: Could not read CSS file ${file}:`, e.message);
54+
return '';
55+
}
56+
})
57+
.filter(Boolean)
58+
.join('\n');
59+
60+
// Combine global styles first, then component styles
61+
cachedStyles = globalStyles + '\n' + componentStyles;
62+
return cachedStyles;
63+
}
64+
65+
/**
66+
* Clears the cached styles (useful for development when styles change)
67+
*/
68+
export function clearStyleCache() {
69+
cachedStyles = null;
70+
}

0 commit comments

Comments
 (0)